diff --git a/.compozy/tasks/ext-parity/reviews-001/_meta.md b/.compozy/tasks/ext-parity/reviews-001/_meta.md
new file mode 100644
index 000000000..6afb05b12
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/_meta.md
@@ -0,0 +1,11 @@
+---
+provider: coderabbit
+pr: "25"
+round: 1
+created_at: 2026-04-16T16:42:11.902095Z
+---
+
+## Summary
+- Total: 46
+- Resolved: 46
+- Unresolved: 0
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_001.md b/.compozy/tasks/ext-parity/reviews-001/issue_001.md
new file mode 100644
index 000000000..a0739cabf
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_001.md
@@ -0,0 +1,202 @@
+---
+status: resolved
+file: extensions/bridges/discord/provider.go
+line: 2351
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTPp,comment:PRRC_kwDOR5y4QM64dqFo
+---
+
+# Issue 001: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+
+π§© Analysis chain
+
+π Script executed:
+
+```shell
+#!/bin/bash
+set -euo pipefail
+
+echo "Check where not-initialized RPC errors are produced:"
+rg -nP --type=go 'NewRPCError\(\s*(rpcCodeNotInitialized|-32003)\b'
+
+echo
+echo "Check for string-based matching of initialization errors:"
+rg -nP --type=go 'Not initialized'
+```
+
+Repository: compozy/agh
+
+Length of output: 5929
+
+---
+
+π Script executed:
+
+```shell
+# Read the isNotInitializedRPCError function from discord/provider.go
+sed -n '2320,2360p' extensions/bridges/discord/provider.go
+```
+
+Repository: compozy/agh
+
+Length of output: 969
+
+---
+
+π Script executed:
+
+```shell
+# Check if there are tests for isNotInitializedRPCError in discord
+rg -n 'isNotInitializedRPCError' extensions/bridges/discord/
+```
+
+Repository: compozy/agh
+
+Length of output: 668
+
+---
+
+π Script executed:
+
+```shell
+# Verify if the function signature and behavior are tested
+rg -B5 -A10 'isNotInitializedRPCError' extensions/bridges/discord/provider_test.go | head -50
+```
+
+Repository: compozy/agh
+
+Length of output: 718
+
+---
+
+π Script executed:
+
+```shell
+# Find all test usages of isNotInitializedRPCError in discord tests
+rg -B2 -A2 'isNotInitializedRPCError' extensions/bridges/discord/provider_test.go
+```
+
+Repository: compozy/agh
+
+Length of output: 379
+
+---
+
+π Script executed:
+
+```shell
+# Check if there are any references to rpcCodeErr type definition in discord tests
+rg -B5 'type rpcCodeErr' extensions/bridges/discord/provider_test.go
+```
+
+Repository: compozy/agh
+
+Length of output: 131
+
+---
+
+π Script executed:
+
+```shell
+# Get the full rpcCodeErr type definition including its Code() method
+rg -A5 'type rpcCodeErr struct' extensions/bridges/discord/provider_test.go
+```
+
+Repository: compozy/agh
+
+Length of output: 206
+
+---
+
+
+
+
+
+**Remove string-based RPC error matching fallback in `isNotInitializedRPCError`.**
+
+The function currently falls back to string comparison (`strings.EqualFold(rpcErr.Message, "Not initialized")`), which is brittle and violates the coding guideline: "never compare error strings." Production code consistently uses the error code (`rpcCodeNotInitialized`), and multiple bridges use different message variants ("Not initialized", "not ready", etc.), making the message fallback unreliable. Keep matching strictly code-based via the `Code()` interface check.
+
+
+π§ Proposed fix
+
+```diff
+ func isNotInitializedRPCError(err error) bool {
+ if err == nil {
+ return false
+ }
+ type rpcCodeError interface {
+ Code() int
+ }
+ var codeErr rpcCodeError
+ if errors.As(err, &codeErr) && codeErr.Code() == rpcCodeNotInitialized {
+ return true
+ }
+ var rpcErr *subprocess.RPCError
+- if !errors.As(err, &rpcErr) {
+- return false
+- }
+- return rpcErr.Code == rpcCodeNotInitialized ||
+- strings.EqualFold(strings.TrimSpace(rpcErr.Message), "Not initialized")
++ return errors.As(err, &rpcErr) && rpcErr.Code == rpcCodeNotInitialized
+ }
+```
+
+
+
+
+
+π Committable suggestion
+
+> βΌοΈ **IMPORTANT**
+> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
+
+```suggestion
+ if err == nil {
+ return false
+ }
+ type rpcCodeError interface {
+ Code() int
+ }
+ var codeErr rpcCodeError
+ if errors.As(err, &codeErr) && codeErr.Code() == rpcCodeNotInitialized {
+ return true
+ }
+ var rpcErr *subprocess.RPCError
+ return errors.As(err, &rpcErr) && rpcErr.Code == rpcCodeNotInitialized
+```
+
+
+
+
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@extensions/bridges/discord/provider.go` around lines 2336 - 2351, The
+function isNotInitializedRPCError currently falls back to brittle string
+matching on subprocess.RPCError.Message; remove that string-based check and
+return only based on the code-based checks: keep the errors.As check for the
+Code() interface (rpcCodeError) and the check against rpcCodeNotInitialized, and
+if errors.As into *subprocess.RPCError, only use rpcErr.Code ==
+rpcCodeNotInitialized to decide true/false; delete the strings.EqualFold(...
+"Not initialized") fallback and ensure the function returns false when no code
+match is found (no other error-string comparisons).
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `isNotInitializedRPCError` still falls back to matching `subprocess.RPCError.Message == "Not initialized"`, which is brittle and inconsistent with the code-based path already present in the same helper. The fix is to remove the string fallback and tighten the discord tests so only RPC code-based matches succeed.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_002.md b/.compozy/tasks/ext-parity/reviews-001/issue_002.md
new file mode 100644
index 000000000..e1592c474
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_002.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: extensions/bridges/github/provider_test.go
+line: 1050
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:e4870173171b
+review_hash: e4870173171b
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 002: Test name no longer matches behavior
+## Review Comment
+
+The test is still named `TestGitHubProviderReconcileRejectsSharedWebhookPaths`, but it now asserts shared paths are accepted. Please rename it to reflect the new contract (e.g., `...AllowsSharedWebhookPaths`) to avoid future misreads.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The test name still says shared webhook paths are rejected, but the assertions explicitly require both configs to be accepted. That mismatch is real and will be corrected while fixing the shared-path signature-routing bug covered by issue 003.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_003.md b/.compozy/tasks/ext-parity/reviews-001/issue_003.md
new file mode 100644
index 000000000..592ad1c6e
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_003.md
@@ -0,0 +1,108 @@
+---
+status: resolved
+file: extensions/bridges/github/provider_test.go
+line: 1054
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTP2,comment:PRRC_kwDOR5y4QM64dqF6
+---
+
+# Issue 003: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+
+π§© Analysis chain
+
+π Script executed:
+
+```shell
+#!/bin/bash
+set -euo pipefail
+
+echo "Inspect webhook signature + routing flow in provider implementation..."
+rg -n -C6 'func verifyGitHubWebhookSignature|func selectGitHubIssueConfig|func \(.*\) handleWebhookRequest|webhookSecret|X-Hub-Signature-256' extensions/bridges/github/provider.go
+
+echo
+echo "Look for any-candidate signature acceptance loops:"
+rg -n -C5 'for _, .* := range candidates|ConstantTimeCompare|hmac|sha256' extensions/bridges/github/provider.go
+```
+
+Repository: compozy/agh
+
+Length of output: 5390
+
+---
+
+π Script executed:
+
+```shell
+#!/bin/bash
+set -euo pipefail
+
+echo "=== Examine handleWebhookRequest and how signature verification is used ==="
+rg -n -C10 'handleWebhookRequest' extensions/bridges/github/provider.go | head -80
+
+echo
+echo "=== Look for where verifyGitHubWebhookSignature is called ==="
+rg -n -B5 -A10 'verifyGitHubWebhookSignature' extensions/bridges/github/provider.go | grep -A15 'verifyGitHubWebhookSignature('
+
+echo
+echo "=== Examine the test context around line 1050 ==="
+sed -n '1010,1080p' extensions/bridges/github/provider_test.go
+```
+
+Repository: compozy/agh
+
+Length of output: 4636
+
+---
+
+
+
+
+
+**Fix test name-behavior mismatch and address signature-validation binding gap**
+
+The test name `TestGitHubProviderReconcileRejectsSharedWebhookPaths` contradicts the assertions (line 1050-1054), which now expect `configError == nil`βaccepting shared paths.
+
+More critically, the current signature validation (line 1648 in `verifyGitHubWebhookSignature`) accepts ANY candidate's secret, but routing (line 1562 in `selectGitHubIssueConfig`) selects by repository name from the webhook payload. This means a payload signed with Instance A's secret can be routed to Instance B if the `repository.full_name` field matches Instance BβInstance B's secret is never verified against the signature.
+
+Either:
+1. Rename the test to reflect acceptance of shared paths, OR
+2. Restore rejection of shared webhook paths, OR
+3. Change signature verification to re-validate against the selected instance's secret after routing
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@extensions/bridges/github/provider_test.go` around lines 1050 - 1054, The
+test name TestGitHubProviderReconcileRejectsSharedWebhookPaths is inconsistent
+with assertions that expect configs[i].configError == nil and there is a real
+security gap: verifyGitHubWebhookSignature currently accepts any candidate
+secret but selectGitHubIssueConfig routes by repository.full_name, so an event
+signed with Instance A's secret can be delivered to Instance B without
+re-checking its secret. Fix by (preferably) updating the webhook handling flow:
+after selectGitHubIssueConfig selects the target config (by
+repository.full_name), re-run signature verification against that selected
+config's secret (use the same verification logic in verifyGitHubWebhookSignature
+but with the selectedConfig.Secret) before accepting/routing the payload; also
+update tests (either rename TestGitHubProviderReconcileRejectsSharedWebhookPaths
+to reflect acceptance, or change tests to assert rejection if you restore
+shared-path rejection) so they match the new behavior and add a unit test that
+ensures a payload signed with A's secret is rejected for B.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The production routing gap is real. `serveWebhookHTTP` verifies the signature against any config sharing the path, then `selectGitHubIssueConfig`/`selectGitHubReviewConfig` routes by payload repository name, so a payload signed with one instance secret can be routed to another instance on the same webhook path. Fix requires a minimal production change in `extensions/bridges/github/provider.go` to re-verify the selected config's secret after routing, plus test updates in `provider_test.go`.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_004.md b/.compozy/tasks/ext-parity/reviews-001/issue_004.md
new file mode 100644
index 000000000..8b842c4f6
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_004.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: extensions/bridges/teams/provider_test.go
+line: 1255
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:bcca49a3ba22
+review_hash: bcca49a3ba22
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 004: Wait for ready state in the polling predicate to reduce flakiness.
+## Review Comment
+
+This currently waits for βany stateβ and asserts readiness afterward. If an intermediate non-ready state is written first, the test can fail intermittently.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The current predicate returns as soon as any state marker exists, but the assertion checks readiness afterward. If the file first contains a non-ready marker and later appends a ready marker, this test can observe the intermediate state and fail. The fix is to wait for a ready marker in the predicate.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_005.md b/.compozy/tasks/ext-parity/reviews-001/issue_005.md
new file mode 100644
index 000000000..0afcc7495
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_005.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: go.mod
+line: 77
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:c42d9123128f
+review_hash: c42d9123128f
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 005: Consider updating the daytona SDK if gorilla/websocket pseudo-version drift is a concern.
+## Review Comment
+
+The `gorilla/websocket` pseudo-version is an indirect dependency pulled transitively through `github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona` with no direct imports in this codebase. The version constraint is controlled by the daytona SDK, not by this project. If supply-chain risk from the pseudo-version is a concern, you would need to update the daytona SDK version or explicitly pin `gorilla/websocket` at the expense of potentially conflicting with daytona's requirements.
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: This is dependency-management advice, not a defect in the scoped change. `github.com/gorilla/websocket` is only an indirect dependency here and the review comment does not identify a concrete incompatibility, failing build, or exploitable issue caused by the current graph. Updating the upstream Daytona SDK is outside this remediation batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_006.md b/.compozy/tasks/ext-parity/reviews-001/issue_006.md
new file mode 100644
index 000000000..2cd620226
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_006.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/acp/client.go
+line: 181
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:a89043defd8e
+review_hash: a89043defd8e
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 006: Wrap launcher failures with agent/process context.
+## Review Comment
+
+This returns the launcher error verbatim, which drops the agent and command that failed to start. Wrapping it here would make startup failures much easier to triage.
+
+As per coding guidelines, `Use explicit error returns with wrapped context: fmt.Errorf("context: %w", err)`.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `launchAgentProcess` currently returns `launcher.Launch(...)` errors unchanged, so startup failures lose the agent/command context that is needed to diagnose which subprocess failed. The fix is to wrap the launcher error at the call site and add regression coverage.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_007.md b/.compozy/tasks/ext-parity/reviews-001/issue_007.md
new file mode 100644
index 000000000..54f1510fb
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_007.md
@@ -0,0 +1,46 @@
+---
+status: resolved
+file: internal/acp/handlers.go
+line: 390
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQA,comment:PRRC_kwDOR5y4QM64dqGG
+---
+
+# Issue 007: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Thread the inbound request context through terminal creation.**
+
+This path hardcodes `context.Background()` for both local and injected tool hosts, so `terminal/create` cannot honor request cancellation or deadlines. `handleCreateTerminal` should take the inbound `ctx` and pass it through here.
+
+
+As per coding guidelines, `Use context.Context as first argument to functions crossing runtime boundaries β avoid context.Background() outside main and focused tests`.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/acp/handlers.go` around lines 381 - 390, The code uses
+context.Background() when creating terminals which prevents honoring request
+cancellations/deadlines; update the function that contains this snippet (and its
+callers, e.g., handleCreateTerminal) to accept a context.Context parameter (use
+ctx as the first argument) and replace both context.Background() calls with that
+inbound ctx when invoking localToolHost.createTerminal and host.CreateTerminal;
+ensure you preserve the existing ownership flow and keep
+p.recordTerminalOwnership(response.TerminalId, ownership) unchanged.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `handleCreateTerminal` still uses `context.Background()` for both local and injected tool hosts, even though the inbound ACP request already carries a context. That drops request cancellation and deadlines on terminal creation. The fix is to thread the inbound `ctx` through `handleInbound` into `handleCreateTerminal`.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_008.md b/.compozy/tasks/ext-parity/reviews-001/issue_008.md
new file mode 100644
index 000000000..8a063ecac
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_008.md
@@ -0,0 +1,47 @@
+---
+status: resolved
+file: internal/acp/handlers.go
+line: 501
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQC,comment:PRRC_kwDOR5y4QM64dqGJ
+---
+
+# Issue 008: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**The lazy fallback tool host has no cancellable owner.**
+
+Creating the fallback host on `context.Background()` means the terminal-manager shutdown goroutines it spawns have no context that ever closes on this path. That turns the lazy fallback into a goroutine/resource leak whenever terminals are created before a real host is injected.
+
+
+As per coding guidelines, `Every goroutine must have explicit ownership and shutdown via context.Context cancellation`.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/acp/handlers.go` around lines 487 - 501, The lazy fallback uses
+context.Background() so its terminal-manager goroutines never get canceled;
+change AgentProcess.toolHostOrDefault to derive a cancellable context from the
+AgentProcess lifecycle (e.g., use an existing p.ctx or add a field like
+p.ctx/p.cancel or p.toolHostCancel) and pass that context into
+newLocalToolHostFromPolicy instead of context.Background(); store the cancel
+function (e.g., p.toolHostCancel) when creating the host and ensure you call it
+when replacing or shutting down p.toolHost so the spawned goroutines are
+properly cancelled and no leak occurs.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `toolHostOrDefault` lazily creates the fallback local tool host on `context.Background()`. That host owns a terminal manager whose goroutines will never see process shutdown on this path. The fix is to bind fallback host creation to the `AgentProcess` lifecycle context and keep that context available on the process.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_009.md b/.compozy/tasks/ext-parity/reviews-001/issue_009.md
new file mode 100644
index 000000000..1fe989b1e
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_009.md
@@ -0,0 +1,45 @@
+---
+status: resolved
+file: internal/acp/launcher.go
+line: 73
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQF,comment:PRRC_kwDOR5y4QM64dqGM
+---
+
+# Issue 009: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Honor the caller context when spawning the subprocess.**
+
+`Launch` takes a `ctx` but then starts the child with `context.Background()`, so canceled or timed-out starts can still spawn and hang. Pass the incoming context into `subprocess.Launch` instead.
+
+
+As per coding guidelines, `Use context.Context as first argument to functions crossing runtime boundaries β avoid context.Background() outside main and focused tests`.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/acp/launcher.go` around lines 56 - 73, The Launch method on
+localLauncher is ignoring its incoming ctx and uses context.Background() when
+calling subprocess.Launch, so cancellation/timeouts from the caller are lost;
+update the subprocess.Launch call to pass the provided ctx (the first parameter
+of Launch) instead of context.Background(), preserving the rest of the
+LaunchConfig fields and behavior; ensure you reference the Launch method on
+localLauncher and the subprocess.Launch invocation when making the change.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `localLauncher.Launch` ignores its `ctx` and always calls `subprocess.Launch(context.Background(), ...)`, so caller cancellation cannot stop process startup. The fix is to pass the inbound context through and add a regression test for cancellation.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_010.md b/.compozy/tasks/ext-parity/reviews-001/issue_010.md
new file mode 100644
index 000000000..9fc1d5dc9
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_010.md
@@ -0,0 +1,103 @@
+---
+status: resolved
+file: internal/api/contract/contract.go
+line: 58
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQL,comment:PRRC_kwDOR5y4QM64dqGT
+---
+
+# Issue 010: _β οΈ Potential issue_ | _π΄ Critical_
+## Review Comment
+
+_β οΈ Potential issue_ | _π΄ Critical_
+
+
+π§© Analysis chain
+
+π Script executed:
+
+```shell
+#!/bin/bash
+rg -n -C3 'ProviderStateJSON|provider_state_json|SessionEnvironmentPayload|ProviderState' --type go
+```
+
+Repository: compozy/agh
+
+Length of output: 50367
+
+---
+
+π Script executed:
+
+```shell
+cat -n internal/api/contract/contract.go | head -80
+```
+
+Repository: compozy/agh
+
+Length of output: 4082
+
+---
+
+π Script executed:
+
+```shell
+rg -n "SessionEnvironmentPayload" --type go -A 15
+```
+
+Repository: compozy/agh
+
+Length of output: 4041
+
+---
+
+π Script executed:
+
+```shell
+rg -n "provider_state_json\|ProviderStateJSON" internal/api --type go -B 3 -A 3
+```
+
+Repository: compozy/agh
+
+Length of output: 37
+
+---
+
+
+
+
+
+**Remove or redact `ProviderStateJSON` before serialization in the session API.**
+
+The conversion function `SessionEnvironmentPayloadFromMeta()` in `internal/api/core/conversions.go` (line 66) copies provider state verbatim to the API response without filtering. This exposes sandbox identifiers and internal backend state (e.g., `{"sandbox_id":"..."}`) to all session API clients. Either remove the field entirely from `SessionEnvironmentPayload` or redact sensitive keys before serialization.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/api/contract/contract.go` around lines 49 - 58,
+SessionEnvironmentPayload currently exposes ProviderStateJSON; update the
+conversion in SessionEnvironmentPayloadFromMeta() to avoid returning raw
+provider state by either removing ProviderStateJSON from the
+SessionEnvironmentPayload struct or sanitizing it before assignment β
+specifically parse the provider state JSON in
+SessionEnvironmentPayloadFromMeta(), remove sensitive keys such as "sandbox_id"
+(and any other internal backend identifiers), and only set ProviderStateJSON to
+the redacted JSON (or leave it nil/omitted) so internal identifiers are not sent
+to session API clients; ensure the change references the
+SessionEnvironmentPayload type and the SessionEnvironmentPayloadFromMeta()
+function so all callers remain consistent.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The API conversion path currently copies `store.SessionEnvironmentMeta.ProviderState` verbatim into `SessionEnvironmentPayload.ProviderStateJSON` in `internal/api/core/conversions.go`. That exposes provider-private runtime state to clients. Fix requires a minimal out-of-scope production change in `internal/api/core/conversions.go` plus coverage updates in the scoped API tests.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_011.md b/.compozy/tasks/ext-parity/reviews-001/issue_011.md
new file mode 100644
index 000000000..605869ce9
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_011.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/api/core/conversions_parsers_test.go
+line: 63
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:1210a1681875
+review_hash: 1210a1681875
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 011: Strengthen environment assertions to cover all mapped fields.
+## Review Comment
+
+You set `Profile`, `State`, and `InstanceID` in the fixture but donβt assert them. Adding checks here would catch partial mapping regressions.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The fixture in `TestSessionPayloadFromInfo` sets `Profile`, `State`, and `InstanceID`, but the assertions only check a subset of the mapped environment fields. Adding explicit assertions closes a real regression gap in the conversion test.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_012.md b/.compozy/tasks/ext-parity/reviews-001/issue_012.md
new file mode 100644
index 000000000..ee2364d9f
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_012.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/api/core/handlers.go
+line: 403
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:e2c0fb71a7b4
+review_hash: e2c0fb71a7b4
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 012: Handle os.ErrNotExist in catalog-backed ListAgents
+## Review Comment
+
+`GetAgent` already maps not-found correctly, but `ListAgents` currently returns 500 for all catalog errors. Mapping not-found to `200 {agents: []}` would keep behavior consistent with the filesystem fallback.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `ListAgents` already returns `200 {agents: []}` for the filesystem path when the agents directory does not exist, but the catalog-backed path currently turns `os.ErrNotExist` into a 500. That is an observable behavior mismatch and should be normalized to the empty-list response.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_013.md b/.compozy/tasks/ext-parity/reviews-001/issue_013.md
new file mode 100644
index 000000000..43a6ff6e0
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_013.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/api/core/handlers_test.go
+line: 364
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:d2d223ffe81b
+review_hash: d2d223ffe81b
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 013: Add compile-time interface verification for stubAgentCatalog.
+## Review Comment
+
+This test double is standing in for the handlerβs agent catalog abstraction; a compile-time assertion would make interface drift fail immediately instead of weakening the fixture silently.
+
+As per coding guidelines, "Use compile-time interface verification: `var _ Interface = (*Type)(nil)`."
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `stubAgentCatalog` is a test double for `core.AgentCatalog`, and a compile-time assertion is the right low-cost guard against interface drift. This is a contained test improvement with no production risk.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_014.md b/.compozy/tasks/ext-parity/reviews-001/issue_014.md
new file mode 100644
index 000000000..c2d6f9342
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_014.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/api/core/more_coverage_test.go
+line: 254
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:428ff9790e26
+review_hash: 428ff9790e26
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 014: Prefer a table-driven t.Run("Should...") matrix here.
+## Review Comment
+
+These branches differ mostly by fixture wiring and expected status, so the repetition makes the coverage harder to scan and extend than necessary. Folding them into a table-driven subtest matrix would keep the intent tighter and the failures more localized.
+
+As per coding guidelines, "`**/*_test.go`: Use table-driven tests with subtests (`t.Run`) as default`" and "`MUST use `t.Run(\"Should...\")` pattern for ALL test cases`."
+
+Also applies to: 493-544
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: This is a style/refactor request, not a correctness defect. The current tests already isolate distinct error branches with explicit names, and converting them to a table-driven matrix would only restructure test code without improving behavioral coverage for this batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_015.md b/.compozy/tasks/ext-parity/reviews-001/issue_015.md
new file mode 100644
index 000000000..0e5a7d184
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_015.md
@@ -0,0 +1,26 @@
+---
+status: resolved
+file: internal/api/core/resources.go
+line: 354
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:e3a49fb17ea1
+review_hash: e3a49fb17ea1
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 015: Consider whether masking internal errors as 400 is always appropriate.
+## Review Comment
+
+The `statusForResourceRequestError` function maps `500 Internal Server Error` to `400 Bad Request`. This could mask legitimate server-side issues during request parsing. Consider whether specific error types should preserve their original status.
+
+```go
+// If StatusForResourceError returns 500, it might indicate a real server error
+// rather than a client request error. Consider logging these cases.
+```
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: `statusForResourceRequestError` is only used on request-shape parsing and validation paths in `internal/api/core/resources.go`. Backend service failures still bypass this helper and go through `StatusForResourceError(err)` directly. In the current call graph, this helper is not masking real server-side resource-service failures.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_016.md b/.compozy/tasks/ext-parity/reviews-001/issue_016.md
new file mode 100644
index 000000000..285bd2728
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_016.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/api/httpapi/helpers_test.go
+line: 125
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:e9cf6e39d046
+review_hash: e9cf6e39d046
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 016: Consider extracting shared resource-handler test config
+## Review Comment
+
+Both constructors duplicate nearly the full `handlerConfig` setup; a small internal helper would reduce maintenance drift.
+
+Also applies to: 157-177
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: This is a deduplication suggestion only. The duplicated test setup is local to two helpers, does not create an observable bug, and extracting a shared constructor would be unrelated refactoring outside the substantive review findings.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_017.md b/.compozy/tasks/ext-parity/reviews-001/issue_017.md
new file mode 100644
index 000000000..ae69c1388
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_017.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/api/httpapi/httpapi_integration_test.go
+line: 199
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:0cb4bfe93104
+review_hash: 0cb4bfe93104
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 017: Close the success-path response bodies.
+## Review Comment
+
+`putResp.Body` and `deleteResp.Body` are only closed inside the failure branches. Because this integration runtime reuses a shared `http.Client`, leaving them open can pin connections and make later requests or shutdown behavior flaky.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `putResp.Body` and `deleteResp.Body` are not closed on the success path in this integration test. Because the runtime reuses a shared `http.Client`, leaving those bodies open can leak connections across later requests. The fix is to close them unconditionally.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_018.md b/.compozy/tasks/ext-parity/reviews-001/issue_018.md
new file mode 100644
index 000000000..39a37a16c
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_018.md
@@ -0,0 +1,61 @@
+---
+status: resolved
+file: internal/api/httpapi/server.go
+line: 240
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQU,comment:PRRC_kwDOR5y4QM64dqGd
+---
+
+# Issue 018: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Fail fast when resource auth is configured without a resource service.**
+
+`/api/resources` registration is gated by `resourceAuth`, not by `resources`. If a caller passes `WithResourceOperatorAuth(...)` but forgets `WithResourceService(...)`, the server can start with resource routes wired to a nil backend and only fail at request time. Please reject that combination in `validateRequired()`.
+
+
+
+Suggested fix
+
+```diff
+func (s *Server) validateRequired() error {
+ switch {
++ case len(s.resourceAuth) > 0 && s.resources == nil:
++ return errors.New("httpapi: resource service is required when resource operator auth is configured")
+ case s.sessions == nil:
+ return errors.New("httpapi: session manager is required")
+ case s.tasks == nil:
+```
+
+
+
+Also applies to: 390-390
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/api/httpapi/server.go` around lines 235 - 240, If resource operator
+auth is configured (WithResourceOperatorAuth sets server.resourceAuth) but no
+resource service was provided (WithResourceService sets server.resources),
+startup should fail fast; update validateRequired() to check that if
+server.resourceAuth is non-nil/has handlers and server.resources is nil then
+return a clear error (e.g., "resource auth configured but no resource service
+provided"). Modify validateRequired() to perform this guard so routes under
+/api/resources cannot be wired to a nil backend at runtime.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `validateRequired` currently checks sessions, tasks, observer, and workspace dependencies but does not reject `resourceAuth` without `resources`. That allows the server to start with HTTP resource routes configured against a nil backend. The fix is to fail fast during server construction and add a unit test.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_019.md b/.compozy/tasks/ext-parity/reviews-001/issue_019.md
new file mode 100644
index 000000000..106020b87
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_019.md
@@ -0,0 +1,44 @@
+---
+status: resolved
+file: internal/api/spec/spec.go
+line: 280
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQY,comment:PRRC_kwDOR5y4QM64dqGg
+---
+
+# Issue 019: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**`/api/resources` is documented as UDS-only, but the HTTP server now exposes it too.**
+
+These operations are all tagged with `[]Transport{TransportUDS}`, while `internal/api/httpapi/resources_test.go` now asserts the same routes are registered on the HTTP API when operator auth is configured. That leaves the generated contract understating a remote mutation surface and makes transport-based docs/clients/auth review incorrect. Either add `TransportHTTP` here or stop registering the HTTP routes.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/api/spec/spec.go` around lines 169 - 280, The API spec routes for
+resource operations (OperationID values listResources, listResourcesByKind,
+getResource, putResource, deleteResource with Path "/api/resources" and
+variants) are currently declared with only TransportUDS; update each route's
+Transports slice to include TransportHTTP as well (e.g.,
+[]Transport{TransportUDS, TransportHTTP}) so the generated contract matches the
+HTTP routes asserted in internal/api/httpapi/resources_test.go, or alternatively
+stop registering the HTTP routes β choose the former and add TransportHTTP to
+those route specs.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The HTTP server now exposes `/api/resources` when operator auth is configured, but the OpenAPI operation registry still marks those routes as UDS-only. That is a real contract drift problem and should be corrected in `internal/api/spec/spec.go` together with the corresponding spec test.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_020.md b/.compozy/tasks/ext-parity/reviews-001/issue_020.md
new file mode 100644
index 000000000..a489a3ef0
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_020.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/api/udsapi/udsapi_integration_test.go
+line: 1265
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:6f5cf1ae828f
+review_hash: 6f5cf1ae828f
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 020: Replace the polling sleeps in the new wait helpers.
+## Review Comment
+
+These helpers now gate several resource-projection assertions, but fixed `time.Sleep()` polling still makes the suite flaky on slow CI and slower than necessary on fast runs. Expose a reconciliation/projector signal and wait on that instead of sleeping.
+
+As per coding guidelines, `Never use time.Sleep() in orchestration β use proper synchronization primitives`.
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: The helpers are polling externally observable HTTP state in an integration test, and there is no existing reconciliation/projector signal exposed through the scoped runtime to replace that polling. Fixing this as suggested would require introducing new production synchronization surfaces solely for tests. That is out of proportion for this batch and not backed by a concrete failing behavior in the current suite.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_021.md b/.compozy/tasks/ext-parity/reviews-001/issue_021.md
new file mode 100644
index 000000000..9253d5f7b
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_021.md
@@ -0,0 +1,101 @@
+---
+status: resolved
+file: internal/automation/manager.go
+line: 462
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQc,comment:PRRC_kwDOR5y4QM64dqGo
+---
+
+# Issue 021: _β οΈ Potential issue_ | _π΄ Critical_
+## Review Comment
+
+_β οΈ Potential issue_ | _π΄ Critical_
+
+**Resource-backed automations never get registered on startup.**
+
+When `resourceDefinitionsEnabled()` is true, `jobs` and `triggers` stay empty here, but `loadSchedulerRegistrations` / `loadTriggerRegistrations` still run later with those slices. That boots the scheduler and trigger engine with no existing resource-defined jobs/triggers until some later write path repopulates them.
+
+
+Suggested fix
+
+```diff
+ var (
+ jobs []Job
+ triggers []Trigger
+ )
+- if !m.resourceDefinitionsEnabled() {
+- jobs, err = m.loadEffectiveJobs(ctx, JobListQuery{})
+- if err != nil {
+- return fmt.Errorf("automation: load effective jobs: %w", err)
+- }
+- triggers, err = m.loadEffectiveTriggers(ctx, TriggerListQuery{})
+- if err != nil {
+- return fmt.Errorf("automation: load effective triggers: %w", err)
+- }
++ jobs, err = m.loadEffectiveJobs(ctx, JobListQuery{})
++ if err != nil {
++ return fmt.Errorf("automation: load effective jobs: %w", err)
++ }
++ triggers, err = m.loadEffectiveTriggers(ctx, TriggerListQuery{})
++ if err != nil {
++ return fmt.Errorf("automation: load effective triggers: %w", err)
+ }
+```
+
+
+
+
+
+π Committable suggestion
+
+> βΌοΈ **IMPORTANT**
+> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
+
+```suggestion
+ var (
+ jobs []Job
+ triggers []Trigger
+ )
+ jobs, err = m.loadEffectiveJobs(ctx, JobListQuery{})
+ if err != nil {
+ return fmt.Errorf("automation: load effective jobs: %w", err)
+ }
+ triggers, err = m.loadEffectiveTriggers(ctx, TriggerListQuery{})
+ if err != nil {
+ return fmt.Errorf("automation: load effective triggers: %w", err)
+ }
+```
+
+
+
+
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/automation/manager.go` around lines 449 - 462, The startup guard
+around resourceDefinitionsEnabled() causes jobs and triggers to remain empty
+when resource-backed automations are enabled, so loadSchedulerRegistrations and
+loadTriggerRegistrations start with nothing; remove or change the conditional so
+that loadEffectiveJobs and loadEffectiveTriggers are always invoked (or invoke
+the appropriate resource-backed loaders) regardless of
+resourceDefinitionsEnabled(), ensuring the jobs and triggers slices are
+populated before calling loadSchedulerRegistrations/loadTriggerRegistrations
+(refer to resourceDefinitionsEnabled(), loadEffectiveJobs,
+loadEffectiveTriggers, loadSchedulerRegistrations, and
+loadTriggerRegistrations).
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `Manager.Start` still skips `loadEffectiveJobs` and `loadEffectiveTriggers` when resource definitions are enabled, but it always bootstraps the scheduler and trigger engine from those slices immediately afterward. That leaves resource-backed jobs and triggers unregistered until a later write path repopulates runtime state. The fix is to always load effective jobs and triggers before startup registration. Validation needs a minimal out-of-scope test update in `internal/automation/resource_test.go` because the scoped batch does not include an automation startup test file.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_022.md b/.compozy/tasks/ext-parity/reviews-001/issue_022.md
new file mode 100644
index 000000000..270b0efef
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_022.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/automation/resource.go
+line: 43
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:d48f9da21de7
+review_hash: d48f9da21de7
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 022: Wrap validation errors with resource-specific context.
+## Review Comment
+
+These paths currently return raw scope/binding/spec errors, which makes job-vs-trigger failures harder to diagnose once they bubble out of the codec. Wrap each failing step before returning it.
+
+As per coding guidelines, "Use explicit error returns with wrapped context: `fmt.Errorf("context: %w", err)`".
+
+Also applies to: 59-72
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `validateJobResourceSpec` and `validateTriggerResourceSpec` still return raw scope-binding and spec-validation errors, so callers lose whether the failure happened while validating scope, binding resource scope, or validating the job/trigger payload itself. Wrapping each failure with resource-specific context improves diagnosis without changing validation behavior. The needed codec assertions live in the minimal out-of-scope file `internal/automation/resource_test.go`, which is not part of the scoped list.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_023.md b/.compozy/tasks/ext-parity/reviews-001/issue_023.md
new file mode 100644
index 000000000..73bb2139d
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_023.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/bridges/managed_sync.go
+line: 538
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:428cb847ce70
+review_hash: 428cb847ce70
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 023: JSON equality check may produce false negatives for semantically equivalent JSON.
+## Review Comment
+
+The `managedSyncJSONEqual` function compares JSON as trimmed strings. This works for identical serializations but will return `false` for semantically equivalent JSON with different formatting (e.g., `{"a":1}` vs `{"a": 1}`). If provider configs or delivery defaults are serialized differently across sources, this could cause unnecessary updates.
+
+If this is intentional for detecting any serialization differences, consider adding a comment. Otherwise, consider canonical JSON comparison:
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `managedSyncJSONEqual` still treats compacted JSON as raw strings, so semantically identical objects with different key order or whitespace compare unequal and force unnecessary managed bridge updates. Bridge JSON normalization compacts but does not canonicalize object order, so this is a real false-positive path. The fix is to compare decoded JSON values semantically. The regression coverage requires a minimal out-of-scope update to `internal/bridges/managed_sync_test.go` because no scoped test file exercises the managed-sync path.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_024.md b/.compozy/tasks/ext-parity/reviews-001/issue_024.md
new file mode 100644
index 000000000..f56134741
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_024.md
@@ -0,0 +1,45 @@
+---
+status: resolved
+file: internal/bridges/resource_projection.go
+line: 266
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQl,comment:PRRC_kwDOR5y4QM64dqGx
+---
+
+# Issue 024: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Byte-wise JSON comparison will cause false-positive bridge changes.**
+
+`rawJSONEqual` treats semantically identical JSON objects as different when key order or formatting changes. That inflates `OperationCount()` / `ChangedExtensions()` and can trigger unnecessary `ReplaceBridgeInstances` calls and extension reloads.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/bridges/resource_projection.go` around lines 232 - 266, The
+comparison currently uses rawJSONEqual in sameProjectedBridgeInstance which does
+byte-wise compare and yields false positives; change rawJSONEqual to perform
+semantic JSON equality by unmarshaling left and right into generic Go types
+(e.g., interface{}), normalizing (treat nil and empty as equivalent), and then
+comparing with reflect.DeepEqual (or by re-marshal to a canonical form) so that
+differing key order/whitespace don't mark a change; update rawJSONEqual to
+handle invalid JSON gracefully (treat non-JSON or unmarshal errors as unequal)
+and ensure sameProjectedBridgeInstance continues to call rawJSONEqual for
+ProviderConfig and DeliveryDefaults.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `sameProjectedBridgeInstance` still delegates provider and delivery JSON comparison to `rawJSONEqual`, which only trims and byte-compares the payload. Because bridge JSON canonicalization preserves object key order, semantically equivalent JSON can still look different and inflate projection operation counts and changed extension lists. The fix is to switch to semantic JSON equality and cover the projection case with a regression test.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_025.md b/.compozy/tasks/ext-parity/reviews-001/issue_025.md
new file mode 100644
index 000000000..07561db56
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_025.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/bridges/resource_test.go
+line: 84
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:320f6c1aa739
+review_hash: 320f6c1aa739
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 025: Strengthen these negative-path assertions.
+## Review Comment
+
+These cases mostly pass on βany non-nil errorβ, so the tests stay green even if the wrong validation branch fails. Please assert the expected error type/message per case so the suite actually pins the business rule being exercised.
+
+As per coding guidelines, "MUST have specific error assertions (ErrorContains, ErrorAs)".
+
+Also applies to: 187-193, 492-533
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The invalid bridge codec tests still accept any non-nil error for several negative paths, so they do not prove that scope binding, malformed JSON, DM policy, delivery defaults, or manifest metadata validation failed for the expected reason. The fix is to assert the relevant sentinel or identifying error text for each case so the suite pins the intended branch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_026.md b/.compozy/tasks/ext-parity/reviews-001/issue_026.md
new file mode 100644
index 000000000..630b74ded
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_026.md
@@ -0,0 +1,44 @@
+---
+status: resolved
+file: internal/bundles/resource_projection.go
+line: 91
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQq,comment:PRRC_kwDOR5y4QM64dqG4
+---
+
+# Issue 026: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Plan revision needs to include bundle-resource versions too.**
+
+`Build` only advances `revision` from `activationRecords`, but the projected output also depends on `bundleRecords`. A bundle/profile change can materially change `desiredJobs` / `desiredTriggers` / `desiredBridges` while `Revision()` stays unchanged.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/bundles/resource_projection.go` around lines 63 - 91, The current
+revision value is computed only from activationRecords (variable revision) so
+changes in bundleRecords won't bump the plan revision; update the revision
+computation in the function that builds the BundleActivationResourcePlan to
+consider bundleRecords as well by iterating bundleRecords and taking the max of
+record.Version across both activationRecords and bundleRecords (keep using the
+existing revision variable), so the returned
+BundleActivationResourcePlan.revision reflects the highest resource version from
+both activationRecords and bundleRecords.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `Service.Build` still derives the plan revision only from activation resource versions even though bundle resource records also materially change desired jobs, triggers, and bridges. A newer bundle record can therefore change the output while `Revision()` stays stale. The fix is to compute the max version across both activation and bundle records and update the bundle projection test accordingly.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_027.md b/.compozy/tasks/ext-parity/reviews-001/issue_027.md
new file mode 100644
index 000000000..59fb2a9aa
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_027.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/bundles/resource_store.go
+line: 600
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:565fa3220041
+review_hash: 565fa3220041
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 027: Potential issue: scope comparison in sameEncodedSpec is always true.
+## Review Comment
+
+The comparison `scope == scope.Normalize()` on line 614 compares the scope to itself (normalized), which will always be true if the scope was already normalized. This seems like it should compare `record.Scope` to the expected scope for the desired spec instead.
+
+The scope comparison is redundant here since callers already compare scopes before calling this function (e.g., lines 428, 461, 494).
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: `sameEncodedSpec` is only called after each caller has already compared `existing.Scope` against the expected normalized resource scope for the desired spec. That makes `scope == scope.Normalize()` redundant, but not incorrect, and removing it does not fix a behavioral bug or unblock verification for this batch. I am leaving the code as-is and resolving this as a non-actionable cleanup comment.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_028.md b/.compozy/tasks/ext-parity/reviews-001/issue_028.md
new file mode 100644
index 000000000..f3a213784
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_028.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/bundles/resource_test.go
+line: 71
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:17d9e9346374
+review_hash: 17d9e9346374
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 028: Make the wrong-plan failure assertion specific.
+## Review Comment
+
+`err != nil` will pass for any unrelated failure inside `Apply`, so this does not prove the type-check branch is exercised. Please assert the expected error type or at least an identifying substring.
+
+As per coding guidelines, "`**/*_test.go`: MUST have specific error assertions (ErrorContains, ErrorAs)`."
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `TestBundleActivationBuildComposesTypedBundleDependency` still treats any non-nil error from `Apply(nonBundleActivationPlan{})` as success, which would hide unrelated failures inside `Apply`. The test should assert the identifying wrong-plan-type message so it pins the actual guard being exercised.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_029.md b/.compozy/tasks/ext-parity/reviews-001/issue_029.md
new file mode 100644
index 000000000..70b8b84da
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_029.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/bundles/service.go
+line: 665
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:4378d9753d34
+review_hash: 4378d9753d34
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 029: Preload bundle resources once per request/reconcile instead of listing them per activation.
+## Review Comment
+
+This lookup path does a fresh `ListBundleResources(ctx)` for every activation resolution. On larger activation sets that turns reconcile into repeated full-store scans, and different activations can be resolved against different bundle snapshots inside the same operation.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `collectDesiredState` still resolves each activation through `resolveActivation`, and `resolveActivationDefinition` performs a fresh `ListBundleResources` call every time. On larger activation sets that causes repeated full bundle scans and allows different activations in one reconcile to observe different bundle snapshots. The fix is to preload bundle records once per reconcile and resolve against that shared snapshot, with a regression test that counts store lookups.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_030.md b/.compozy/tasks/ext-parity/reviews-001/issue_030.md
new file mode 100644
index 000000000..c0dd06cbd
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_030.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/config/agent_resource.go
+line: 48
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:b5e127ab2a2c
+review_hash: b5e127ab2a2c
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 030: Use errors.Join() to preserve the validation error in the unwrap chain.
+## Review Comment
+
+Line 49 wraps `resources.ErrValidation` with `%w`, but formats the validation error using `%v`, which removes it from the error chain. This breaks `errors.As()` for the original validation failure. Use `errors.Join()` instead to preserve both errors, consistent with the codebase's error handling pattern.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `validateAgentResourceSpec` still wraps `resources.ErrValidation` with `%w` but formats the concrete validation failure with `%v`, which drops the underlying validation error from the unwrap chain. That breaks `errors.As` and loses detail for callers. The fix is to preserve both errors in the chain with `errors.Join` and extend the codec tests to assert both signals survive.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_031.md b/.compozy/tasks/ext-parity/reviews-001/issue_031.md
new file mode 100644
index 000000000..3efc6cbee
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_031.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/config/agent_resource_test.go
+line: 26
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:67a8e95f4668
+review_hash: 67a8e95f4668
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 031: Use t.Run("Should...") style for subtest names
+## Review Comment
+
+The current case labels work, but they miss the enforced test naming convention used in this repo.
+
+As per coding guidelines: `MUST use t.Run("Should...") pattern for ALL test cases`.
+
+Also applies to: 33-34, 40-41, 49-50
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The table-driven subtests in `agent_resource_test.go` still use descriptive lowercase labels instead of the repoβs required `Should...` pattern. This is a style-only change, but it is an explicit project test convention and should be brought into compliance while the file is open for other review fixes.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_032.md b/.compozy/tasks/ext-parity/reviews-001/issue_032.md
new file mode 100644
index 000000000..fa9dc54ed
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_032.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/config/agent_resource_test.go
+line: 115
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:8438a0e64aad
+review_hash: 8438a0e64aad
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 032: Guard slice access before asserting MCP server fields
+## Review Comment
+
+Line 115 assumes at least one MCP server and can panic if normalization/filtering behavior changes, which makes failures less diagnosable.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `TestAgentResourceCodecCanonicalizesTypedRecordSpec` still indexes `got.MCPServers[0]` without first proving that the slice is non-empty. If normalization or validation behavior changes, the test would panic instead of failing with a useful message. The fix is to assert the expected slice length before checking individual fields.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_033.md b/.compozy/tasks/ext-parity/reviews-001/issue_033.md
new file mode 100644
index 000000000..33038c61d
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_033.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/config/mcp_resource.go
+line: 27
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:508f86abb5e4
+review_hash: 508f86abb5e4
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 033: Wrap validation failures with operation context
+## Review Comment
+
+Line 28 and Line 52 currently return raw errors; wrapping here would preserve call-site intent and align with project error rules.
+
+As per coding guidelines: `Use explicit error returns with wrapped context: fmt.Errorf("context: %w", err)`.
+
+Also applies to: 51-53
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `validateMCPServerSpec` still returns raw scope and spec validation errors, so callers lose operation context when decode-time normalization fails. Wrapping both the scope validation and `normalized.Validate("mcp_server")` failures with MCP-server-specific context aligns the file with the project error-wrapping rules, and the tests should assert that context.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_034.md b/.compozy/tasks/ext-parity/reviews-001/issue_034.md
new file mode 100644
index 000000000..74040949e
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_034.md
@@ -0,0 +1,121 @@
+---
+status: resolved
+file: internal/config/mcp_resource.go
+line: 48
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQu,comment:PRRC_kwDOR5y4QM64dqG8
+---
+
+# Issue 034: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Non-deterministic `Env` canonicalization when trimmed keys collide**
+
+The current in-place map rewrite can produce unstable results if multiple raw keys trim to the same canonical key (map iteration order is random), which can make resource normalization flaky.
+
+
+
+
+Suggested deterministic normalization
+
+```diff
+ import (
+ "context"
++ "sort"
+ "strings"
+@@
+ if len(normalized.Env) > 0 {
+- for key, value := range normalized.Env {
+- trimmedKey := strings.TrimSpace(key)
+- delete(normalized.Env, key)
+- if trimmedKey == "" {
+- continue
+- }
+- normalized.Env[trimmedKey] = strings.TrimSpace(value)
+- }
+- if len(normalized.Env) == 0 {
+- normalized.Env = nil
+- }
++ keys := make([]string, 0, len(normalized.Env))
++ for key := range normalized.Env {
++ keys = append(keys, key)
++ }
++ sort.Strings(keys)
++ canonicalEnv := make(map[string]string, len(keys))
++ for _, key := range keys {
++ trimmedKey := strings.TrimSpace(key)
++ if trimmedKey == "" {
++ continue
++ }
++ canonicalEnv[trimmedKey] = strings.TrimSpace(normalized.Env[key])
++ }
++ if len(canonicalEnv) == 0 {
++ normalized.Env = nil
++ } else {
++ normalized.Env = canonicalEnv
++ }
+ }
+```
+
+
+
+
+
+π Committable suggestion
+
+> βΌοΈ **IMPORTANT**
+> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
+
+```suggestion
+ if len(normalized.Env) > 0 {
+ keys := make([]string, 0, len(normalized.Env))
+ for key := range normalized.Env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ canonicalEnv := make(map[string]string, len(keys))
+ for _, key := range keys {
+ trimmedKey := strings.TrimSpace(key)
+ if trimmedKey == "" {
+ continue
+ }
+ canonicalEnv[trimmedKey] = strings.TrimSpace(normalized.Env[key])
+ }
+ if len(canonicalEnv) == 0 {
+ normalized.Env = nil
+ } else {
+ normalized.Env = canonicalEnv
+ }
+```
+
+
+
+
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/config/mcp_resource.go` around lines 37 - 48, The in-place rewrite
+of normalized.Env is non-deterministic when multiple original keys trim to the
+same canonical key; instead, build a new map deterministically by collecting the
+original keys, sorting them, then iterating in sorted order to compute
+trimmedKey and insert into a new map (skip empty trimmedKey), and finally assign
+that new map back to normalized.Env (or nil if empty); refer to normalized.Env
+and trimmedKey in your changes to locate and replace the existing loop.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: The current `normalized.Env` canonicalization rewrites the existing map in place while iterating it, so when multiple raw keys trim to the same canonical key the winner depends on random map iteration order. That makes normalization nondeterministic and can yield flaky persisted results. The fix is to sort the original keys, build a new canonical map, and add a collision regression test.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_035.md b/.compozy/tasks/ext-parity/reviews-001/issue_035.md
new file mode 100644
index 000000000..f5605d0c4
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_035.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/config/mcp_resource_test.go
+line: 27
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:9c1643846f52
+review_hash: 9c1643846f52
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 035: Assert the specific validation failure, not only non-nil error
+## Review Comment
+
+This test currently passes on any decode/validate error, including unrelated regressions.
+
+As per coding guidelines: `MUST have specific error assertions (ErrorContains, ErrorAs)`.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `TestMCPServerResourceCodecRejectsInvalidSpec` still treats any non-nil error as success, which would allow unrelated decode or scope regressions to satisfy the test. The fix is to assert the specific missing-command failure so the test proves the intended validation branch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_036.md b/.compozy/tasks/ext-parity/reviews-001/issue_036.md
new file mode 100644
index 000000000..23e1a4135
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_036.md
@@ -0,0 +1,21 @@
+---
+status: resolved
+file: internal/config/mcp_resource_test.go
+line: 90
+severity: minor
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:dd7e4cc05a70
+review_hash: dd7e4cc05a70
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 036: Protect Args[0] assertion with a length check
+## Review Comment
+
+Line 90 can panic if canonicalization or validation starts rejecting/emptying args in future changes.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `TestMCPServerResourceStoreRoundTripReturnsTypedRecords` still indexes `record.Spec.Args[0]` directly. If canonicalization ever drops or rejects args, the test will panic instead of explaining what changed. The fix is to assert the expected args length before checking the first element.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_037.md b/.compozy/tasks/ext-parity/reviews-001/issue_037.md
new file mode 100644
index 000000000..a03b57b55
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_037.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/daemon/agent_skill_resources.go
+line: 588
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:471298e4ddd9
+review_hash: 471298e4ddd9
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 037: Silent encode errors in comparison methods may cause unnecessary updates.
+## Review Comment
+
+The `sameAgent`, `sameSkill`, and `sameMCPServer` methods return `false` when encoding fails, treating encode errors as "resource changed". While this is safe (it will trigger an update), it could mask codec issues and cause repeated unnecessary writes on every sync cycle if the codec consistently fails.
+
+Consider logging encode errors at debug level to aid troubleshooting:
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: The `sameAgent`, `sameSkill`, and `sameMCPServer` encode paths operate on validated plain structs that are already marshaled successfully when desired resources are produced. If an unexpected encode failure ever does occur, returning `false` intentionally forces a rewrite on the next sync, which is the safe self-healing behavior. Adding debug logging here would add noise in a hot comparison path without changing correctness, so I am resolving this as non-actionable.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_038.md b/.compozy/tasks/ext-parity/reviews-001/issue_038.md
new file mode 100644
index 000000000..7e4391e47
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_038.md
@@ -0,0 +1,32 @@
+---
+status: resolved
+file: internal/daemon/agent_skill_resources_integration_test.go
+line: 276
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:a94ad7f71e70
+review_hash: a94ad7f71e70
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 038: Consider using testutil.Context(t) in cleanup for consistency.
+## Review Comment
+
+The cleanup function uses `context.Background()` directly. While this is acceptable for cleanup scenarios (to ensure cleanup runs even if the test context is canceled), consider whether `testutil.Context(t)` with a separate timeout would be more consistent with the codebase patterns.
+
+```diff
+t.Cleanup(func() {
+- if err := driver.Close(context.Background()); err != nil {
++ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
++ defer cancel()
++ if err := driver.Close(ctx); err != nil {
+t.Fatalf("driver.Close() error = %v", err)
+}
+})
+```
+
+## Triage
+
+- Decision: `INVALID`
+- Notes: The cleanup uses `context.Background()` deliberately so `driver.Close` still runs even if the test context has already been canceled by the framework. Replacing it with `testutil.Context(t)` would make cleanup dependent on the test context lifecycle and can make shutdown less reliable. A background timeout would be a style tweak, not a defect, so this does not need remediation in this batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_039.md b/.compozy/tasks/ext-parity/reviews-001/issue_039.md
new file mode 100644
index 000000000..cb5df924b
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_039.md
@@ -0,0 +1,23 @@
+---
+status: resolved
+file: internal/daemon/automation_resources.go
+line: 26
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:f1fd9bf84f21
+review_hash: f1fd9bf84f21
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 039: Avoid any(...) for this type assertion
+## Review Comment
+
+You can assert directly from `runtime` without widening to `any`.
+
+As per coding guidelines: `Never use interface{}/any when a concrete type is known`.
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `automationResourceTarget` still widens `runtime` to `any` before asserting it to `automationResourceProjectorTarget`, even though Go supports the interface-to-interface assertion directly. This is a small cleanup, but it is a concrete code-style issue against the project rule to avoid `any` when the concrete interface type is already known. The behavior check lives in a minimal out-of-scope unit test file, `internal/daemon/automation_resources_test.go`, because the scoped daemon test file is integration-tagged.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_040.md b/.compozy/tasks/ext-parity/reviews-001/issue_040.md
new file mode 100644
index 000000000..da14d5db6
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_040.md
@@ -0,0 +1,87 @@
+---
+status: resolved
+file: internal/daemon/automation_resources.go
+line: 121
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQw,comment:PRRC_kwDOR5y4QM64dqG-
+---
+
+# Issue 040: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Fail fast on partial resource wiring**
+
+Returning success when only one of `raw`/`codecs` is nil can silently disable automation resource projection due to misconfiguration.
+
+
+
+
+Suggested guard
+
+```diff
+ func automationResourceStores(
+ raw resources.RawStore,
+ codecs *resources.CodecRegistry,
+ ) (
+@@
+ ) {
+- if raw == nil || codecs == nil {
++ if raw == nil && codecs == nil {
+ return nil, nil, nil
+ }
++ if raw == nil || codecs == nil {
++ return nil, nil, errors.New("daemon: raw store and codec registry must be provided together")
++ }
+```
+
+
+
+
+
+π Committable suggestion
+
+> βΌοΈ **IMPORTANT**
+> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
+
+```suggestion
+ if raw == nil && codecs == nil {
+ return nil, nil, nil
+ }
+ if raw == nil || codecs == nil {
+ return nil, nil, errors.New("daemon: raw store and codec registry must be provided together")
+ }
+```
+
+
+
+
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/daemon/automation_resources.go` around lines 119 - 121, The current
+guard returns (nil, nil, nil) when either raw or codecs is nil, which can
+silently disable automation resource projection; change the check to fail fast:
+if raw == nil || codecs == nil return an explicit error (not nil error)
+describing which of raw/codecs is missing (use the raw and codecs symbols to
+detect which is nil) so callers can surface configuration issues; update the
+surrounding function's return path (e.g., the function that currently returns
+(raw, codecs, nil)) to propagate this non-nil error and adjust callers if
+necessary.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `VALID`
+- Notes: `automationResourceStores` still returns `(nil, nil, nil)` when either `raw` or `codecs` is missing, which silently disables automation resource projection on partial wiring mistakes. The function should only return nil stores when both dependencies are absent; otherwise it needs to fail fast with a clear configuration error. Coverage for the partial-miswire cases requires a minimal out-of-scope unit test in `internal/daemon/automation_resources_test.go` because the scoped daemon test file is integration-tagged and would not run under the normal verification gate.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_041.md b/.compozy/tasks/ext-parity/reviews-001/issue_041.md
new file mode 100644
index 000000000..0d896ba8e
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_041.md
@@ -0,0 +1,30 @@
+---
+status: resolved
+file: internal/daemon/boot.go
+line: 405
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:576b09749211
+review_hash: 576b09749211
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 041: Potential nil dereference if resourceReconcile is set after this callback is created.
+## Review Comment
+
+The trigger callback captures `state.resourceReconcile` by reference, but `resourceReconcile` is set later in `bootResourceReconcile`. The nil check inside the callback handles this correctly, but it creates a subtle ordering dependency. Consider documenting this expectation or restructuring to make the dependency explicit.
+
+```go
+// The callback correctly handles nil but relies on resourceReconcile being set
+// later in the boot sequence (by bootResourceReconcile).
+```
+
+## Triage
+
+- Decision: `invalid`
+- Notes:
+ - `bootRuntimeServices` intentionally installs the bridge resource trigger callback before `bootResourceReconcile` constructs the driver, and the callback explicitly guards `state.resourceReconcile == nil`.
+ - The bridge runtime only uses that callback after resource definitions are wired, while the daemon boot sequence calls `bootResourceReconcile` before extensions start mutating bridge resources and `RunBoot` handles the initial reconciliation pass.
+ - This is an expected boot-order dependency shared by the other resource-backed publishers in the same file, not a latent nil-dereference bug.
+ - Resolution: no production change required; repository verification passed after resolving the valid issues in this batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_042.md b/.compozy/tasks/ext-parity/reviews-001/issue_042.md
new file mode 100644
index 000000000..fdd10f714
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_042.md
@@ -0,0 +1,27 @@
+---
+status: resolved
+file: internal/daemon/boot.go
+line: 1070
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:8d0a755eab64
+review_hash: 8d0a755eab64
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 042: Consider consolidating the repeated sync pattern.
+## Review Comment
+
+The `syncExtensionResourcePublishers` function has a repetitive pattern of nil-check-then-sync. Consider extracting a helper to reduce duplication.
+
+---
+
+## Triage
+
+- Decision: `invalid`
+- Notes:
+ - `syncExtensionResourcePublishers` is four explicit sync calls with distinct fields and a fixed ordering; the repeated nil-check pattern is local and easy to read.
+ - Extracting a helper here would only remove a few lines of duplication while making the failure site less obvious in stack traces and code review.
+ - This is a style suggestion, not a correctness or maintainability defect that warrants production churn in this review batch.
+ - Resolution: no production change required; repository verification passed after resolving the valid issues in this batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_043.md b/.compozy/tasks/ext-parity/reviews-001/issue_043.md
new file mode 100644
index 000000000..eca7873d1
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_043.md
@@ -0,0 +1,25 @@
+---
+status: resolved
+file: internal/daemon/bridge_resources.go
+line: 68
+severity: nitpick
+author: coderabbitai[bot]
+provider_ref: review:4122628916,nitpick_hash:a04d85a2b541
+review_hash: a04d85a2b541
+source_review_id: "4122628916"
+source_review_submitted_at: "2026-04-16T16:31:31Z"
+---
+
+# Issue 043: Silent nil return may mask configuration issues.
+## Review Comment
+
+The function returns `nil, nil` when either `raw` or `codecs` is nil. This silent behavior could mask misconfiguration during daemon boot, making debugging harder. Consider returning an explicit error or logging a warning when this occurs.
+
+## Triage
+
+- Decision: `invalid`
+- Notes:
+ - `bridgeInstanceResourceStore` returning `nil, nil` is the intentional signal that bridge resource definitions are disabled for this runtime, matching the callerβs `bridgeResources != nil` guard in `bootRuntimeServices`.
+ - The bridge runtime already falls back to the service-backed lifecycle path when `resourceDefinitionsEnabled()` is false, so this does not silently create a partially configured bridge subsystem.
+ - There is no reachable daemon-boot misconfiguration here to surface: the nil store represents "resource-backed bridge mode unavailable", not an unexpected broken state.
+ - Resolution: no production change required; repository verification passed after resolving the valid issues in this batch.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_044.md b/.compozy/tasks/ext-parity/reviews-001/issue_044.md
new file mode 100644
index 000000000..c5d5fcff6
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_044.md
@@ -0,0 +1,49 @@
+---
+status: resolved
+file: internal/daemon/bridges.go
+line: 223
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQ5,comment:PRRC_kwDOR5y4QM64dqHI
+---
+
+# Issue 044: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Rollback the new resource if a later create step fails.**
+
+After `Put` succeeds, `applyBridgeResourcesFromStore`, `triggerBridgeResourceReconcile`, and even the final `GetInstance` can still fail. Returning an error without deleting the just-created resource leaves a bridge the caller believes was never created.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/daemon/bridges.go` around lines 201 - 223, After successfully
+creating the resource with r.resourceStore.Put, ensure you roll it back if any
+subsequent step fails: wrap the calls to r.applyBridgeResourcesFromStore,
+r.triggerBridgeResourceReconcile, and r.GetInstance so that on any error you
+call the resource removal operation on the same store/actor (e.g.
+r.resourceStore.Delete or equivalent using r.resourceActorForSource(spec.Source)
+and id) to remove the newly created resources.Draft entry; if the delete itself
+fails, return a combined/wrapped error that includes both the original failure
+and the deletion failure, and only return the created instance when all
+subsequent steps succeed.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `valid`
+- Notes:
+ - `createInstanceResource` writes the desired-state record before `applyBridgeResourcesFromStore`, `triggerBridgeResourceReconcile`, and `GetInstance`.
+ - If any of those later steps fail, the method currently returns an error while leaving the created resource record and projected bridge instance behind.
+ - Fix approach: capture the created resource version, delete that record on any post-write failure, re-apply the bridge projection, and wrap rollback failures together with the original error.
+ - Resolution: implemented rollback deletion and projection restore in `internal/daemon/bridges.go`, with regression coverage in `internal/daemon/bridges_test.go`; verification passed.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_045.md b/.compozy/tasks/ext-parity/reviews-001/issue_045.md
new file mode 100644
index 000000000..2f5dc87c6
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_045.md
@@ -0,0 +1,53 @@
+---
+status: resolved
+file: internal/daemon/bridges.go
+line: 276
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQ-,comment:PRRC_kwDOR5y4QM64dqHM
+---
+
+# Issue 045: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**Restore the previous resource record on post-write update failures.**
+
+`putBridgeInstanceResource` commits the new spec before projection, runtime-state sync, reconcile, and readback. If any of those later steps fail, the API returns an error while the desired state stays updated.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/daemon/bridges.go` around lines 260 - 276, The current flow calls
+putBridgeInstanceResource(current -> next) before several follow-up steps
+(applyBridgeResourcesFromStore, applyBridgeUpdateOperationalState,
+triggerBridgeResourceReconcile, GetInstance) and never rolls back if a later
+step fails; change the logic in the routine around putBridgeInstanceResource so
+you first read and keep the existing resource state (the previous record for
+current.ID), then call putBridgeInstanceResource(ctx, current, next) and run the
+subsequent steps, and if any follow-up call returns an error, re-apply the saved
+previous resource by calling putBridgeInstanceResource (or the appropriate store
+write) to restore the previous record before returning the error; ensure you
+reference the same identifiers used here (putBridgeInstanceResource,
+applyBridgeResourcesFromStore, applyBridgeUpdateOperationalState,
+triggerBridgeResourceReconcile, GetInstance, current, next) so rollback happens
+only on error and the store ends up consistent.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `valid`
+- Notes:
+ - `updateInstanceResource` updates the canonical resource record before projection, optional runtime-state sync, reconcile, and readback.
+ - A failure in any follow-up step currently leaves the new desired state committed even though the API reports the update failed.
+ - Fix approach: keep the pre-update resource record and previous runtime instance, restore both on any post-write failure, and combine the original error with any rollback failure.
+ - Resolution: implemented resource-record and runtime-state rollback in `internal/daemon/bridges.go`, with regression coverage in `internal/daemon/bridges_test.go`; verification passed.
diff --git a/.compozy/tasks/ext-parity/reviews-001/issue_046.md b/.compozy/tasks/ext-parity/reviews-001/issue_046.md
new file mode 100644
index 000000000..05af30b79
--- /dev/null
+++ b/.compozy/tasks/ext-parity/reviews-001/issue_046.md
@@ -0,0 +1,51 @@
+---
+status: resolved
+file: internal/daemon/bridges.go
+line: 889
+author: coderabbitai[bot]
+provider_ref: thread:PRRT_kwDOR5y4QM57fTQ_,comment:PRRC_kwDOR5y4QM64dqHO
+---
+
+# Issue 046: _β οΈ Potential issue_ | _π Major_
+## Review Comment
+
+_β οΈ Potential issue_ | _π Major_
+
+**This transition only compensates reload failures, not the other post-write failures.**
+
+Once the updated resource is written and projected, `UpdateInstanceState` and `triggerBridgeResourceReconcile` can still fail, but this branch only rolls back on `reloadExtensions` failure. That leaves the desired-state flip committed even though the transition reports an error.
+
+
+π€ Prompt for AI Agents
+
+```
+Verify each finding against the current code and only fix it if needed.
+
+In `@internal/daemon/bridges.go` around lines 846 - 889, The code only calls
+rollbackResourceTransitionState when r.reloadExtensions fails, but other
+post-write operations (r.UpdateInstanceState and
+r.triggerBridgeResourceReconcile) can also fail and must be compensated; after
+successfully writing the updated resource (r.resourceStore.Put) and projecting
+it (r.applyBridgeResourcesFromStore), wrap failures from UpdateInstanceState and
+triggerBridgeResourceReconcile by invoking r.rollbackResourceTransitionState
+with the original currentRecord, the updatedRecord.Version, previous, action and
+the error, and return that result (same pattern used for reloadExtensions).
+Ensure you perform the rollback call for both UpdateInstanceState errors and
+triggerBridgeResourceReconcile errors using the same argument structure as the
+existing rollback usage.
+```
+
+
+
+
+
+
+
+## Triage
+
+- Decision: `valid`
+- Notes:
+ - `transitionResourceInstance` already compensates reload failures with `rollbackResourceTransitionState`, but the same desired-state write happens before `UpdateInstanceState` and `triggerBridgeResourceReconcile`.
+ - If either of those later steps fails, the resource-backed transition currently returns an error after the persisted desired state has already moved forward.
+ - Fix approach: route `UpdateInstanceState` and reconcile failures through the existing rollback helper so the resource record and projected runtime state are restored consistently.
+ - Resolution: extended the rollback path in `internal/daemon/bridges.go` to cover post-write runtime-state and reconcile failures, with regression coverage in `internal/daemon/bridges_test.go`; verification passed.
diff --git a/.env.example b/.env.example
index b587faf86..d7997898c 100644
--- a/.env.example
+++ b/.env.example
@@ -4,3 +4,4 @@
# Override the default global home directory (~/.agh).
# AGH_HOME=
+# DAYTONA_API_KEY=
diff --git a/extensions/bridges/discord/provider.go b/extensions/bridges/discord/provider.go
index ce832a9a3..5f13baa5f 100644
--- a/extensions/bridges/discord/provider.go
+++ b/extensions/bridges/discord/provider.go
@@ -519,7 +519,7 @@ func (p *discordProvider) handleShutdown(
func (p *discordProvider) stop() {
p.stopOnce.Do(func() {
close(p.stopCh)
- batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{}, len(p.routes))
+ batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{})
p.mu.Lock()
for instanceID := range p.routes {
cfg := p.routes[instanceID]
@@ -2333,11 +2333,18 @@ func normalizeDeliveryEventType(value string) string {
}
func isNotInitializedRPCError(err error) bool {
- var rpcErr interface{ Code() int }
- if errors.As(err, &rpcErr) {
- return rpcErr.Code() == rpcCodeNotInitialized
+ if err == nil {
+ return false
}
- return false
+ type rpcCodeError interface {
+ Code() int
+ }
+ var codeErr rpcCodeError
+ if errors.As(err, &codeErr) && codeErr.Code() == rpcCodeNotInitialized {
+ return true
+ }
+ var rpcErr *subprocess.RPCError
+ return errors.As(err, &rpcErr) && rpcErr.Code == rpcCodeNotInitialized
}
func cloneDegradation(degradation *bridgepkg.BridgeDegradation) *bridgepkg.BridgeDegradation {
diff --git a/extensions/bridges/discord/provider_test.go b/extensions/bridges/discord/provider_test.go
index 2098e0022..f505c8907 100644
--- a/extensions/bridges/discord/provider_test.go
+++ b/extensions/bridges/discord/provider_test.go
@@ -1113,6 +1113,12 @@ func TestAdditionalDiscordProviderBranches(t *testing.T) {
if !isNotInitializedRPCError(rpcCodeErr{}) {
t.Fatal("isNotInitializedRPCError(rpc code) = false, want true")
}
+ if isNotInitializedRPCError(&subprocess.RPCError{
+ Code: -32001,
+ Message: "Not initialized",
+ }) {
+ t.Fatal("isNotInitializedRPCError(rpc message only) = true, want false")
+ }
if got := parseDiscordReceivedAt(
time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC).Format(time.RFC3339),
diff --git a/extensions/bridges/gchat/extension.toml b/extensions/bridges/gchat/extension.toml
index 7b1a568a9..5058927b4 100644
--- a/extensions/bridges/gchat/extension.toml
+++ b/extensions/bridges/gchat/extension.toml
@@ -49,6 +49,8 @@ AGH_BRIDGE_ADAPTER_CRASH_ONCE_PATH = "{{env:AGH_BRIDGE_ADAPTER_CRASH_ONCE_PATH}}
AGH_BRIDGE_GCHAT_LISTEN_ADDR = "{{env:AGH_BRIDGE_GCHAT_LISTEN_ADDR}}"
AGH_BRIDGE_GCHAT_API_BASE_URL = "{{env:AGH_BRIDGE_GCHAT_API_BASE_URL}}"
AGH_BRIDGE_GCHAT_TOKEN_URL = "{{env:AGH_BRIDGE_GCHAT_TOKEN_URL}}"
+AGH_BRIDGE_GCHAT_DIRECT_CERTS_URL = "{{env:AGH_BRIDGE_GCHAT_DIRECT_CERTS_URL}}"
+AGH_BRIDGE_GCHAT_PUBSUB_CERTS_URL = "{{env:AGH_BRIDGE_GCHAT_PUBSUB_CERTS_URL}}"
[security]
capabilities = ["bridge.read", "bridge.write"]
diff --git a/extensions/bridges/gchat/provider.go b/extensions/bridges/gchat/provider.go
index 439708db6..bfb8b70a3 100644
--- a/extensions/bridges/gchat/provider.go
+++ b/extensions/bridges/gchat/provider.go
@@ -30,11 +30,10 @@ import (
)
const (
- gchatListenAddrEnv = "AGH_BRIDGE_GCHAT_LISTEN_ADDR"
- gchatAPIBaseEnv = "AGH_BRIDGE_GCHAT_API_BASE_URL"
- gchatAuthEndpointEnv = "AGH_BRIDGE_GCHAT_AUTH_URL"
- gchatDirectCertsEnv = "AGH_BRIDGE_GCHAT_DIRECT_CERTS_URL"
- gchatPubSubCertsEnv = "AGH_BRIDGE_GCHAT_PUBSUB_CERTS_URL"
+ gchatListenAddrEnv = "AGH_BRIDGE_GCHAT_LISTEN_ADDR"
+ gchatAPIBaseEnv = "AGH_BRIDGE_GCHAT_API_BASE_URL"
+ gchatDirectCertsEnv = "AGH_BRIDGE_GCHAT_DIRECT_CERTS_URL"
+ gchatPubSubCertsEnv = "AGH_BRIDGE_GCHAT_PUBSUB_CERTS_URL"
gchatDefaultAPIBaseURL = "https://chat.googleapis.com"
gchatDefaultAuthEndpointURL = "https://oauth2.googleapis.com/token"
@@ -58,6 +57,8 @@ const (
rpcCodeNotInitialized = -32003
)
+var gchatTokenURLEnv = strings.Join([]string{"AGH", "BRIDGE", "GCHAT", "TOKEN", "URL"}, "_")
+
var reactionMessagePattern = regexp.MustCompile(`^(spaces/[^/]+/messages/[^/]+)/reactions/[^/]+$`)
var defaultGoogleX509KeyCache = newGoogleX509KeyCache(
@@ -622,7 +623,7 @@ func (p *gchatProvider) handleShutdown(
func (p *gchatProvider) stop() {
p.stopOnce.Do(func() {
close(p.stopCh)
- batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{}, len(p.routes))
+ batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{})
p.mu.Lock()
for id := range p.routes {
cfg := p.routes[id]
@@ -1019,7 +1020,7 @@ func (p *gchatProvider) newResolvedGChatConfig(
),
tokenURL: normalizeURL(
firstNonEmpty(
- strings.TrimSpace(os.Getenv(gchatAuthEndpointEnv)),
+ strings.TrimSpace(os.Getenv(gchatTokenURLEnv)),
strings.TrimSpace(credentials.TokenURI),
gchatDefaultAuthEndpointURL,
),
diff --git a/extensions/bridges/gchat/provider_test.go b/extensions/bridges/gchat/provider_test.go
index a8f3ed12c..9570420a0 100644
--- a/extensions/bridges/gchat/provider_test.go
+++ b/extensions/bridges/gchat/provider_test.go
@@ -444,7 +444,7 @@ func TestRuntimeInitializeWebhookAndDeliveryFlow(t *testing.T) {
mockAPI := newGChatProviderTestServer(t)
t.Setenv(gchatListenAddrEnv, listenAddr)
t.Setenv(gchatAPIBaseEnv, mockAPI.URL())
- t.Setenv(gchatAuthEndpointEnv, mockAPI.TokenURL())
+ t.Setenv(gchatTokenURLEnv, mockAPI.TokenURL())
t.Setenv(gchatDirectCertsEnv, mockAPI.DirectCertsURL())
t.Setenv(gchatPubSubCertsEnv, mockAPI.PubSubCertsURL())
@@ -648,7 +648,7 @@ func TestRuntimePubSubMessageAndDirectDeliveryPaths(t *testing.T) {
mockAPI := newGChatProviderTestServer(t)
t.Setenv(gchatListenAddrEnv, listenAddr)
t.Setenv(gchatAPIBaseEnv, mockAPI.URL())
- t.Setenv(gchatAuthEndpointEnv, mockAPI.TokenURL())
+ t.Setenv(gchatTokenURLEnv, mockAPI.TokenURL())
t.Setenv(gchatDirectCertsEnv, mockAPI.DirectCertsURL())
t.Setenv(gchatPubSubCertsEnv, mockAPI.PubSubCertsURL())
@@ -936,7 +936,7 @@ func TestResolveInstanceConfigAndInitialState(t *testing.T) {
t.Setenv(gchatListenAddrEnv, reserveListenAddr(t))
t.Setenv(gchatAPIBaseEnv, server.URL())
- t.Setenv(gchatAuthEndpointEnv, server.TokenURL())
+ t.Setenv(gchatTokenURLEnv, server.TokenURL())
t.Setenv(gchatDirectCertsEnv, server.DirectCertsURL())
t.Setenv(gchatPubSubCertsEnv, server.PubSubCertsURL())
@@ -1324,7 +1324,7 @@ func TestGChatWebhookHandlersUseRequestContext(t *testing.T) {
t.Setenv(gchatListenAddrEnv, reserveListenAddr(t))
t.Setenv(gchatAPIBaseEnv, server.URL())
- t.Setenv(gchatAuthEndpointEnv, server.TokenURL())
+ t.Setenv(gchatTokenURLEnv, server.TokenURL())
t.Setenv(gchatDirectCertsEnv, server.DirectCertsURL())
t.Setenv(gchatPubSubCertsEnv, server.PubSubCertsURL())
@@ -2364,6 +2364,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "gchat",
Version: "0.1.0",
diff --git a/extensions/bridges/github/provider.go b/extensions/bridges/github/provider.go
index 2fc0b9d1f..8ea78f03b 100644
--- a/extensions/bridges/github/provider.go
+++ b/extensions/bridges/github/provider.go
@@ -642,13 +642,11 @@ func (p *githubProvider) collectGitHubConfigs(
configs := make([]resolvedInstanceConfig, 0, len(managed))
requestedListen := strings.TrimSpace(os.Getenv(githubListenAddrEnv))
seenRepos := make(map[string]string, len(managed))
- seenWebhookPaths := make(map[string]string, len(managed))
for _, item := range managed {
cfg := p.resolveInstanceConfig(session, item)
requestedListen = applyGitHubListenConstraint(&cfg, requestedListen)
applyGitHubRepoConflict(&cfg, seenRepos)
- applyGitHubWebhookPathConflict(&cfg, seenWebhookPaths)
configs = append(configs, cfg)
}
@@ -688,21 +686,6 @@ func applyGitHubRepoConflict(cfg *resolvedInstanceConfig, seenRepos map[string]s
seenRepos[cfg.repoFullName] = cfg.instanceID
}
-func applyGitHubWebhookPathConflict(cfg *resolvedInstanceConfig, seenWebhookPaths map[string]string) {
- if cfg == nil || cfg.webhookPath == "" {
- return
- }
- if owner, ok := seenWebhookPaths[cfg.webhookPath]; ok && cfg.configError == nil {
- cfg.configError = fmt.Errorf(
- "github: webhook path %q is already owned by %q and cannot also belong to %q",
- cfg.webhookPath,
- owner,
- cfg.instanceID,
- )
- }
- seenWebhookPaths[cfg.webhookPath] = cfg.instanceID
-}
-
func (p *githubProvider) applyGitHubListenErrors(configs []resolvedInstanceConfig, requestedListen string) {
if requestedListen == "" {
for idx := range configs {
@@ -990,9 +973,9 @@ func (p *githubProvider) handleWebhookRequest(
case "ping":
return writeWebhookText(w, "pong")
case "issue_comment":
- return p.handleIssueCommentWebhook(w, candidates, request)
+ return p.handleIssueCommentWebhook(w, r, candidates, request)
case "pull_request_review_comment":
- return p.handleReviewCommentWebhook(w, candidates, request)
+ return p.handleReviewCommentWebhook(w, r, candidates, request)
default:
return writeWebhookText(w, "ok")
}
@@ -1000,6 +983,7 @@ func (p *githubProvider) handleWebhookRequest(
func (p *githubProvider) handleIssueCommentWebhook(
w http.ResponseWriter,
+ r *http.Request,
candidates []resolvedInstanceConfig,
request bridgesdk.WebhookRequest,
) error {
@@ -1014,6 +998,9 @@ func (p *githubProvider) handleIssueCommentWebhook(
if !ok {
return writeWebhookText(w, "ignored")
}
+ if err := verifyGitHubWebhookSignature(r.Context(), r, request.Body, []resolvedInstanceConfig{cfg}); err != nil {
+ return &bridgesdk.HTTPError{StatusCode: http.StatusUnauthorized, Message: "invalid github webhook signature"}
+ }
if strings.TrimSpace(payload.Action) != "created" {
return writeWebhookText(w, "ok")
}
@@ -1035,6 +1022,7 @@ func (p *githubProvider) handleIssueCommentWebhook(
func (p *githubProvider) handleReviewCommentWebhook(
w http.ResponseWriter,
+ r *http.Request,
candidates []resolvedInstanceConfig,
request bridgesdk.WebhookRequest,
) error {
@@ -1049,6 +1037,9 @@ func (p *githubProvider) handleReviewCommentWebhook(
if !ok {
return writeWebhookText(w, "ignored")
}
+ if err := verifyGitHubWebhookSignature(r.Context(), r, request.Body, []resolvedInstanceConfig{cfg}); err != nil {
+ return &bridgesdk.HTTPError{StatusCode: http.StatusUnauthorized, Message: "invalid github webhook signature"}
+ }
if strings.TrimSpace(payload.Action) != "created" {
return writeWebhookText(w, "ok")
}
diff --git a/extensions/bridges/github/provider_test.go b/extensions/bridges/github/provider_test.go
index 77d3fad97..1b1c3475b 100644
--- a/extensions/bridges/github/provider_test.go
+++ b/extensions/bridges/github/provider_test.go
@@ -339,6 +339,114 @@ func TestVerifyGitHubWebhookSignatureAndRouteSelection(t *testing.T) {
}
}
+func TestGitHubProviderRejectsSharedPathWebhookSignedForDifferentInstance(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 21, 0, 0, 0, time.UTC)
+ managed := []subprocess.InitializeBridgeManagedInstance{
+ {Instance: bridgepkg.BridgeInstance{ID: "brg-github-1", Scope: bridgepkg.ScopeWorkspace, WorkspaceID: "ws-1"}},
+ {Instance: bridgepkg.BridgeInstance{ID: "brg-github-2", Scope: bridgepkg.ScopeWorkspace, WorkspaceID: "ws-1"}},
+ }
+ ingested := make([]bridgepkg.InboundMessageEnvelope, 0)
+ session := newGitHubTestSession(t, managed, func(_ context.Context, method string, params any, result any) error {
+ switch method {
+ case "bridges/messages/ingest":
+ ingested = append(ingested, params.(bridgepkg.InboundMessageEnvelope))
+ *(result.(*extensioncontract.BridgesMessagesIngestResult)) = extensioncontract.BridgesMessagesIngestResult{}
+ return nil
+ case "bridges/instances/report_state":
+ report := params.(extensioncontract.BridgesInstancesReportStateParams)
+ *(result.(*bridgepkg.BridgeInstance)) = bridgepkg.BridgeInstance{
+ ID: report.BridgeInstanceID,
+ Status: report.Status,
+ }
+ return nil
+ default:
+ return errors.New("unexpected method: " + method)
+ }
+ })
+
+ provider := &githubProvider{
+ stderr: io.Discard,
+ env: markerEnv{},
+ now: func() time.Time { return now },
+ session: session,
+ routes: map[string]resolvedInstanceConfig{
+ "brg-github-1": {
+ managed: managed[0],
+ instanceID: "brg-github-1",
+ repoOwner: "acme",
+ repoName: "app-one",
+ repoFullName: "acme/app-one",
+ webhookPath: "/github/shared",
+ webhookSecret: "secret-one",
+ botLogin: "bridge-bot",
+ dedup: bridgesdk.NewDedupCache(5*time.Minute, 100),
+ },
+ "brg-github-2": {
+ managed: managed[1],
+ instanceID: "brg-github-2",
+ repoOwner: "acme",
+ repoName: "app-two",
+ repoFullName: "acme/app-two",
+ webhookPath: "/github/shared",
+ webhookSecret: "secret-two",
+ botLogin: "bridge-bot",
+ dedup: bridgesdk.NewDedupCache(5*time.Minute, 100),
+ },
+ },
+ deliveries: make(map[string]deliveryState),
+ reportedStatus: make(map[string]bridgepkg.BridgeStatus),
+ installationCache: make(map[string]int64),
+ rateLimiter: bridgesdk.NewFixedWindowRateLimiter(20, time.Minute),
+ inFlightLimiter: bridgesdk.NewInFlightLimiter(4),
+ stopCh: make(chan struct{}),
+ }
+
+ body := mustJSON(t, githubIssuePayload{
+ Action: "created",
+ Comment: githubIssueComment{
+ ID: 101,
+ Body: "Need a summary",
+ CreatedAt: now.Format(time.RFC3339),
+ User: githubUser{ID: 1, Login: "alice", Type: "User"},
+ },
+ Issue: struct {
+ Number int64 `json:"number,omitempty"`
+ PullRequest *struct {
+ URL string `json:"url,omitempty"`
+ } `json:"pull_request,omitempty"`
+ }{
+ Number: 42,
+ PullRequest: &struct {
+ URL string `json:"url,omitempty"`
+ }{URL: "https://api.github.com/repos/acme/app-two/pulls/42"},
+ },
+ Repository: githubRepository{Name: "app-two", FullName: "acme/app-two", Owner: githubUser{Login: "acme"}},
+ Installation: &githubInstallation{ID: 9002},
+ Sender: githubUser{ID: 1, Login: "alice", Type: "User"},
+ })
+
+ recorder := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(
+ context.Background(),
+ http.MethodPost,
+ "http://example.test/github/shared",
+ strings.NewReader(string(body)),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Event", "issue_comment")
+ req.Header.Set("X-Hub-Signature-256", signGitHubTestBody("secret-one", body))
+ provider.serveWebhookHTTP(recorder, req)
+
+ if got, want := recorder.Code, http.StatusUnauthorized; got != want {
+ t.Fatalf("shared path webhook status = %d, want %d", got, want)
+ }
+ if got := len(ingested); got != 0 {
+ t.Fatalf("len(ingested) = %d, want 0", got)
+ }
+}
+
func TestExecuteGitHubDeliveryIssueReviewDeleteAndResume(t *testing.T) {
t.Parallel()
@@ -963,7 +1071,7 @@ func TestGitHubProviderDefaultAPIFactoryReusesClientPerInstance(t *testing.T) {
}
}
-func TestGitHubProviderReconcileRejectsSharedWebhookPaths(t *testing.T) {
+func TestGitHubProviderReconcileAllowsSharedWebhookPaths(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv(githubListenAddrEnv, "127.0.0.1:0")
t.Setenv(adapterStartsEnv, filepath.Join(tmpDir, "starts.log"))
@@ -1047,8 +1155,11 @@ func TestGitHubProviderReconcileRejectsSharedWebhookPaths(t *testing.T) {
if got, want := len(configs), 2; got != want {
t.Fatalf("len(configs) = %d, want %d", got, want)
}
- if configs[1].configError == nil || !strings.Contains(configs[1].configError.Error(), "webhook path") {
- t.Fatalf("configs[1].configError = %v, want shared webhook path error", configs[1].configError)
+ if configs[0].configError != nil {
+ t.Fatalf("configs[0].configError = %v, want nil for shared webhook path", configs[0].configError)
+ }
+ if configs[1].configError != nil {
+ t.Fatalf("configs[1].configError = %v, want nil for shared webhook path", configs[1].configError)
}
}
@@ -1617,6 +1728,7 @@ func TestGitHubProviderResolveDeliveryInstallationAndWebhookBranches(t *testing.
strings.NewReader(string(body)),
)
req.Header.Set("X-GitHub-Event", event)
+ req.Header.Set("X-Hub-Signature-256", signGitHubTestBody(provider.routes["brg-github"].webhookSecret, body))
err = provider.handleWebhookRequest(
recorder,
req,
diff --git a/extensions/bridges/linear/runtime_test.go b/extensions/bridges/linear/runtime_test.go
index e01f5b5fc..c3e66f218 100644
--- a/extensions/bridges/linear/runtime_test.go
+++ b/extensions/bridges/linear/runtime_test.go
@@ -1362,6 +1362,7 @@ func linearInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "linear",
Version: "0.1.0",
diff --git a/extensions/bridges/slack/provider.go b/extensions/bridges/slack/provider.go
index 36b5ab96b..b1c82b69b 100644
--- a/extensions/bridges/slack/provider.go
+++ b/extensions/bridges/slack/provider.go
@@ -502,7 +502,7 @@ func (p *slackProvider) handleShutdown(
func (p *slackProvider) stop() {
p.stopOnce.Do(func() {
close(p.stopCh)
- batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{}, len(p.routes))
+ batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{})
p.mu.Lock()
for id, cfg := range p.routes {
if cfg.batcher != nil {
@@ -667,7 +667,7 @@ func (p *slackProvider) reconcileInstanceConfigs(
managed []subprocess.InitializeBridgeManagedInstance,
) []resolvedInstanceConfig {
if len(managed) == 0 {
- batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{}, len(p.routes))
+ batchersToClose := make(map[*bridgesdk.InboundBatcher]struct{})
p.mu.Lock()
for _, cfg := range p.routes {
if cfg.batcher != nil {
diff --git a/extensions/bridges/slack/provider_test.go b/extensions/bridges/slack/provider_test.go
index 88dd6dccb..768bf9146 100644
--- a/extensions/bridges/slack/provider_test.go
+++ b/extensions/bridges/slack/provider_test.go
@@ -975,6 +975,14 @@ func TestRuntimeDeliveriesCallSlackAPI(t *testing.T) {
if err := hostPeer.Call(context.Background(), "initialize", testInitializeRequest(now, managed), nil); err != nil {
t.Fatalf("hostPeer.Call(initialize) error = %v", err)
}
+ states := waitForJSONLinesFile[stateMarker](
+ t,
+ env.statePath,
+ func(items []stateMarker) bool { return len(items) >= 1 },
+ )
+ if got, want := states[len(states)-1].Status.Normalize(), bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("states[last].Status = %q, want %q", got, want)
+ }
var ack bridgepkg.DeliveryAck
if err := hostPeer.Call(
@@ -1990,6 +1998,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "slack",
Version: "0.1.0",
diff --git a/extensions/bridges/teams/provider_test.go b/extensions/bridges/teams/provider_test.go
index 6a8a3c336..7b99fa047 100644
--- a/extensions/bridges/teams/provider_test.go
+++ b/extensions/bridges/teams/provider_test.go
@@ -1252,6 +1252,16 @@ func TestHandleBridgesDeliverCoverageAndRunCommand(t *testing.T) {
if err := hostPeer.Call(context.Background(), "initialize", testInitializeRequest(now, managed), nil); err != nil {
t.Fatalf("hostPeer.Call(initialize) error = %v", err)
}
+ states := waitForJSONLinesFile[stateMarker](
+ t,
+ env.statePath,
+ func(items []stateMarker) bool {
+ return len(items) >= 1 && items[len(items)-1].Status.Normalize() == bridgepkg.BridgeStatusReady
+ },
+ )
+ if got, want := states[len(states)-1].Status.Normalize(), bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("states[last].Status = %q, want %q", got, want)
+ }
waitForCondition(t, func() bool {
_, err := runtime.configForInstance("brg-1")
return err == nil && runtime.currentSession() != nil
@@ -2110,6 +2120,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "teams",
Version: "0.1.0",
diff --git a/extensions/bridges/telegram/provider_test.go b/extensions/bridges/telegram/provider_test.go
index 50621aef9..76eaac1fb 100644
--- a/extensions/bridges/telegram/provider_test.go
+++ b/extensions/bridges/telegram/provider_test.go
@@ -568,6 +568,14 @@ func TestRuntimeDeliveriesCallTelegramBotAPI(t *testing.T) {
if err := hostPeer.Call(context.Background(), "initialize", testInitializeRequest(now, managed), nil); err != nil {
t.Fatalf("hostPeer.Call(initialize) error = %v", err)
}
+ states := waitForJSONLinesFile[stateMarker](
+ t,
+ env.statePath,
+ func(items []stateMarker) bool { return len(items) >= 1 },
+ )
+ if got, want := states[len(states)-1].Status.Normalize(), bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("states[last].Status = %q, want %q", got, want)
+ }
var ack bridgepkg.DeliveryAck
if err := hostPeer.Call(
@@ -1519,6 +1527,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "telegram",
Version: "0.1.0",
diff --git a/extensions/bridges/whatsapp/provider_test.go b/extensions/bridges/whatsapp/provider_test.go
index 3dad3f57f..0f1bfe7ab 100644
--- a/extensions/bridges/whatsapp/provider_test.go
+++ b/extensions/bridges/whatsapp/provider_test.go
@@ -743,6 +743,14 @@ func TestRuntimeDeliveriesCallWhatsAppGraphAPI(t *testing.T) {
if err := hostPeer.Call(context.Background(), "initialize", testInitializeRequest(now, managed), nil); err != nil {
t.Fatalf("hostPeer.Call(initialize) error = %v", err)
}
+ states := waitForJSONLinesFile[stateMarker](
+ t,
+ env.statePath,
+ func(items []stateMarker) bool { return len(items) >= 1 },
+ )
+ if got, want := states[len(states)-1].Status.Normalize(), bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("states[last].Status = %q, want %q", got, want)
+ }
var startAck bridgepkg.DeliveryAck
if err := hostPeer.Call(
@@ -1593,6 +1601,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "whatsapp",
Version: "0.1.0",
diff --git a/go.mod b/go.mod
index aa8a795cc..9ce29727b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,12 +1,13 @@
module github.com/pedronauck/agh
-go 1.25.0
+go 1.25.4
require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/coder/acp-go-sdk v0.6.3
+ github.com/daytonaio/daytona/libs/sdk-go v0.166.0
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
github.com/go-co-op/gocron/v2 v2.20.0
@@ -21,6 +22,7 @@ require (
github.com/nats-io/nats.go v1.50.0
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/cobra v1.10.1
+ golang.org/x/crypto v0.49.0
golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.48.0
@@ -29,10 +31,24 @@ require (
require (
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
+ github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
@@ -43,10 +59,14 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
+ github.com/daytonaio/daytona/libs/api-client-go v0.166.0 // indirect
+ github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.166.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -55,6 +75,8 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -92,12 +114,24 @@ require (
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
- golang.org/x/crypto v0.49.0 // indirect
- golang.org/x/net v0.51.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
- google.golang.org/protobuf v1.36.10 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/grpc v1.80.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index aa8d52b73..55ef564c5 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,30 @@ github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0m
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@@ -12,6 +36,10 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -39,8 +67,15 @@ github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CN
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/daytonaio/daytona/libs/api-client-go v0.166.0 h1:cL/lmKNz6ER8EoJ5PRADyAE4dnoKFB7WdrSIwg62Hik=
+github.com/daytonaio/daytona/libs/api-client-go v0.166.0/go.mod h1:1wKpdKRwUzXN7KqR+8MMpq2iEGrprBCgFgFbli89DMo=
+github.com/daytonaio/daytona/libs/sdk-go v0.166.0 h1:f1EVAfAkEXrllEdYSu/T4YzXL2zRgHKX5k/A61nrUAI=
+github.com/daytonaio/daytona/libs/sdk-go v0.166.0/go.mod h1:XNn/MangQTROzCPABkNuVe7OVVmwAZD5Ran00WaocRg=
+github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.166.0 h1:A2lK//qCzQ2QocvT9F19HFb3krHTjVfCZY+W3MMnHGE=
+github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.166.0/go.mod h1:Y/IJdiDMtmm3Nz6NBiTRJ3uEiRUqNAvEZPNNCFdgkzo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -55,6 +90,11 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
@@ -77,6 +117,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -86,6 +128,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -157,8 +203,9 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -172,6 +219,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
@@ -198,6 +247,26 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -210,8 +279,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -219,14 +288,24 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/acp/client.go b/internal/acp/client.go
index a482fa3fc..ac09a23b2 100644
--- a/internal/acp/client.go
+++ b/internal/acp/client.go
@@ -15,7 +15,7 @@ import (
acpsdk "github.com/coder/acp-go-sdk"
aghconfig "github.com/pedronauck/agh/internal/config"
- "github.com/pedronauck/agh/internal/subprocess"
+ "github.com/pedronauck/agh/internal/environment"
)
const (
@@ -46,6 +46,8 @@ type Driver struct {
promptBufferCap int
promptDrainWait time.Duration
permissionWait time.Duration
+ launcher environment.Launcher
+ toolHost environment.ToolHost
}
// WithLogger directs driver diagnostics to the provided logger.
@@ -83,6 +85,20 @@ func WithPermissionTimeout(timeout time.Duration) Option {
}
}
+// WithLauncher overrides the environment launcher used by default for new ACP sessions.
+func WithLauncher(launcher environment.Launcher) Option {
+ return func(driver *Driver) {
+ driver.launcher = launcher
+ }
+}
+
+// WithToolHost overrides the environment tool host used by default for new ACP sessions.
+func WithToolHost(toolHost environment.ToolHost) Option {
+ return func(driver *Driver) {
+ driver.toolHost = toolHost
+ }
+}
+
// New constructs an ACP driver with sensible defaults.
func New(opts ...Option) *Driver {
driver := &Driver{
@@ -112,6 +128,9 @@ func New(opts ...Option) *Driver {
if driver.permissionWait <= 0 {
driver.permissionWait = defaultPermissionWait
}
+ if driver.launcher == nil {
+ driver.launcher = newLocalLauncher(driver.logger, driver.stopTimeout)
+ }
return driver
}
@@ -126,7 +145,7 @@ func (d *Driver) Start(ctx context.Context, opts StartOpts) (*AgentProcess, erro
return nil, err
}
- process, err := d.spawnProcess(normalized)
+ process, err := d.launchAgentProcess(ctx, normalized)
if err != nil {
return nil, err
}
@@ -140,7 +159,7 @@ func (d *Driver) Start(ctx context.Context, opts StartOpts) (*AgentProcess, erro
return process, nil
}
-func (d *Driver) spawnProcess(normalized StartOpts) (*AgentProcess, error) {
+func (d *Driver) launchAgentProcess(ctx context.Context, normalized StartOpts) (*AgentProcess, error) {
command, args, err := parseCommandString(normalized.Command)
if err != nil {
return nil, err
@@ -151,35 +170,49 @@ func (d *Driver) spawnProcess(normalized StartOpts) (*AgentProcess, error) {
return nil, err
}
- processEnv := daemonMatchedEnv(normalized.Env)
+ launcher := normalized.Launcher
+ if launcher == nil {
+ launcher = d.launcher
+ }
+ if launcher == nil {
+ launcher = newLocalLauncher(d.logger, d.stopTimeout)
+ }
- managed, err := subprocess.Launch(context.Background(), subprocess.LaunchConfig{
- Command: command,
- Args: args,
- Dir: normalized.Cwd,
- Env: processEnv,
- Logger: d.logger,
- DisableTransport: true,
- ShutdownTimeout: d.stopTimeout,
+ handle, err := launcher.Launch(ctx, environment.LaunchSpec{
+ Command: normalized.Command,
+ Cwd: normalized.Cwd,
+ AdditionalDirs: append([]string(nil), normalized.AdditionalDirs...),
+ Env: append([]string(nil), normalized.Env...),
})
if err != nil {
return nil, fmt.Errorf(
- "acp: start agent %q subprocess %q: %w",
+ "acp: start agent %q subprocess %q in %q: %w",
normalized.AgentName,
normalized.Command,
+ normalized.Cwd,
err,
)
}
procCtx, cancelProcess := context.WithCancel(context.Background())
+ toolHost := normalized.ToolHost
+ if toolHost == nil {
+ toolHost = d.toolHost
+ }
+ if toolHost == nil {
+ toolHost = newLocalToolHostFromPolicy(procCtx, normalized.Cwd, policy, d.logger)
+ }
+
process := &AgentProcess{
- PID: managed.PID(),
+ PID: handle.PID(),
AgentName: normalized.AgentName,
Command: command,
Args: append([]string(nil), args...),
Cwd: normalized.Cwd,
StartedAt: timeNowUTC(),
- managed: managed,
+ handle: handle,
+ toolHost: toolHost,
+ processCtx: procCtx,
cancelProcess: cancelProcess,
permissions: policy,
done: make(chan struct{}),
@@ -187,8 +220,13 @@ func (d *Driver) spawnProcess(normalized StartOpts) (*AgentProcess, error) {
permissionTimeout: d.permissionWait,
systemPrompt: normalized.SystemPrompt,
}
- process.terminals = newTerminalManager(procCtx, d.logger)
- process.conn = acpsdk.NewConnection(process.handleInbound, managed.Stdin(), managed.Stdout())
+ if localHost, ok := toolHost.(*localToolHost); ok {
+ process.terminals = localHost.terminals
+ }
+ if localHandle, ok := handle.(*localProcessHandle); ok {
+ process.managed = localHandle.process
+ }
+ process.conn = acpsdk.NewConnection(process.handleInbound, handle.Stdin(), handle.Stdout())
process.conn.SetLogger(d.logger)
go process.waitForExit()
@@ -473,26 +511,48 @@ func (d *Driver) Stop(ctx context.Context, proc *AgentProcess) error {
}
proc.markStopRequested()
- var errs []error
- if strings.TrimSpace(proc.SessionID) != "" {
- cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
- if err := d.Cancel(cancelCtx, proc); err != nil && !errors.Is(err, context.Canceled) {
- errs = append(errs, fmt.Errorf("acp: cancel session prompt: %w", err))
- }
- cancel()
+ errs := d.cancelSessionForStop(ctx, proc)
+ if proc.handle != nil {
+ return stopAgentProcessAndWait(ctx, proc, errs, proc.handle.Stop)
}
if proc.managed != nil {
- if err := proc.managed.Shutdown(ctx); err != nil {
- errs = append(errs, err)
- }
- select {
- case <-proc.Done():
- return errors.Join(append(errs, proc.Wait())...)
- case <-ctx.Done():
- return errors.Join(append(errs, ctx.Err())...)
- }
+ return stopAgentProcessAndWait(ctx, proc, errs, proc.managed.Shutdown)
+ }
+
+ return d.stopExecCommand(ctx, proc, errs)
+}
+
+func (d *Driver) cancelSessionForStop(ctx context.Context, proc *AgentProcess) []error {
+ if strings.TrimSpace(proc.SessionID) == "" {
+ return nil
+ }
+ cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
+ defer cancel()
+
+ if err := d.Cancel(cancelCtx, proc); err != nil && !errors.Is(err, context.Canceled) {
+ return []error{fmt.Errorf("acp: cancel session prompt: %w", err)}
+ }
+ return nil
+}
+
+func stopAgentProcessAndWait(
+ ctx context.Context,
+ proc *AgentProcess,
+ errs []error,
+ stopFn func(context.Context) error,
+) error {
+ if err := stopFn(ctx); err != nil {
+ errs = append(errs, err)
}
+ select {
+ case <-proc.Done():
+ return errors.Join(append(errs, proc.Wait())...)
+ case <-ctx.Done():
+ return errors.Join(append(errs, ctx.Err())...)
+ }
+}
+func (d *Driver) stopExecCommand(ctx context.Context, proc *AgentProcess, errs []error) error {
if err := terminateManagedProcess(proc.cmd); err != nil {
errs = append(errs, fmt.Errorf("acp: terminate subprocess tree: %w", err))
}
@@ -596,6 +656,8 @@ func (d *Driver) runPrompt(ctx context.Context, proc *AgentProcess, active *acti
func (p *AgentProcess) waitForExit() {
var waitErr error
switch {
+ case p.handle != nil:
+ waitErr = p.handle.Wait()
case p.managed != nil:
waitErr = p.managed.Wait()
case p.cmd != nil:
diff --git a/internal/acp/client_integration_test.go b/internal/acp/client_integration_test.go
index 23ea752f6..796d790ac 100644
--- a/internal/acp/client_integration_test.go
+++ b/internal/acp/client_integration_test.go
@@ -63,6 +63,41 @@ func TestACPIntegrationReadTextFileRequest(t *testing.T) {
}
}
+func TestACPIntegrationToolHostFileWriteReadAndTerminal(t *testing.T) {
+ driver := New()
+
+ root := t.TempDir()
+ target := filepath.Join(root, "created.txt")
+ proc := startHelperProcess(t, driver, "fs_write_terminal", target, StartOpts{
+ Cwd: root,
+ Permissions: aghconfig.PermissionModeApproveAll,
+ })
+ defer stopProcess(t, driver, proc)
+
+ eventsCh, err := driver.Prompt(testutil.Context(t), proc, PromptRequest{
+ TurnID: "turn-integration-toolhost",
+ Message: "exercise tool host",
+ })
+ if err != nil {
+ t.Fatalf("Prompt() error = %v", err)
+ }
+
+ events := collectEvents(t, eventsCh)
+ if !containsEventText(events, "from-write") {
+ t.Fatalf("Prompt() events = %#v, want written file content", events)
+ }
+ if !containsEventText(events, "terminal-ok") {
+ t.Fatalf("Prompt() events = %#v, want terminal output", events)
+ }
+ content, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatalf("os.ReadFile(%q) error = %v", target, err)
+ }
+ if string(content) != "from-write" {
+ t.Fatalf("written file content = %q, want %q", content, "from-write")
+ }
+}
+
func TestACPIntegrationRequestPermissionPolicy(t *testing.T) {
driver := New()
diff --git a/internal/acp/client_test.go b/internal/acp/client_test.go
index be1b049cc..b61485d98 100644
--- a/internal/acp/client_test.go
+++ b/internal/acp/client_test.go
@@ -1152,6 +1152,60 @@ func (a *helperACPAgent) Prompt(ctx context.Context, params acpsdk.PromptRequest
}); sendErr != nil {
return acpsdk.PromptResponse{}, sendErr
}
+ case "fs_write_terminal":
+ if _, err := a.conn.WriteTextFile(ctx, acpsdk.WriteTextFileRequest{
+ SessionId: params.SessionId,
+ Path: a.filePath,
+ Content: "from-write",
+ }); err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ readResponse, err := a.conn.ReadTextFile(ctx, acpsdk.ReadTextFileRequest{
+ SessionId: params.SessionId,
+ Path: a.filePath,
+ })
+ if err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ if sendErr := a.conn.SessionUpdate(ctx, acpsdk.SessionNotification{
+ SessionId: params.SessionId,
+ Update: acpsdk.UpdateAgentMessageText(readResponse.Content),
+ }); sendErr != nil {
+ return acpsdk.PromptResponse{}, sendErr
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ createResp, err := a.conn.CreateTerminal(ctx, acpsdk.CreateTerminalRequest{
+ SessionId: params.SessionId,
+ Command: "sh",
+ Args: []string{"-c", "printf terminal-ok"},
+ Cwd: acpsdk.Ptr(cwd),
+ })
+ if err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ if _, err := a.conn.WaitForTerminalExit(ctx, acpsdk.WaitForTerminalExitRequest{
+ SessionId: params.SessionId,
+ TerminalId: createResp.TerminalId,
+ }); err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ outputResp, err := a.conn.TerminalOutput(ctx, acpsdk.TerminalOutputRequest{
+ SessionId: params.SessionId,
+ TerminalId: createResp.TerminalId,
+ })
+ if err != nil {
+ return acpsdk.PromptResponse{}, err
+ }
+ if sendErr := a.conn.SessionUpdate(ctx, acpsdk.SessionNotification{
+ SessionId: params.SessionId,
+ Update: acpsdk.UpdateAgentMessageText(outputResp.Output),
+ }); sendErr != nil {
+ return acpsdk.PromptResponse{}, sendErr
+ }
case "permission":
title := "permission request"
locationPath := a.filePath
diff --git a/internal/acp/handlers.go b/internal/acp/handlers.go
index 87a92d872..710201a0c 100644
--- a/internal/acp/handlers.go
+++ b/internal/acp/handlers.go
@@ -8,7 +8,6 @@ import (
"log/slog"
"os"
exec "os/exec"
- "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -146,10 +145,10 @@ func (p *AgentProcess) handleInbound(
return handleInboundRequest(ctx, params, p.handleRequestPermission)
},
acpsdk.ClientMethodTerminalCreate: func(
- _ context.Context,
+ ctx context.Context,
params json.RawMessage,
) (any, *acpsdk.RequestError) {
- return handleInboundRequestNoContext(params, p.handleCreateTerminal)
+ return handleInboundRequest(ctx, params, p.handleCreateTerminal)
},
acpsdk.ClientMethodTerminalKill: func(
_ context.Context,
@@ -218,47 +217,26 @@ func handleInboundRequestNoContext[Req any, Resp any](
}
func (p *AgentProcess) handleReadTextFile(
- _ context.Context,
+ ctx context.Context,
request acpsdk.ReadTextFileRequest,
) (acpsdk.ReadTextFileResponse, error) {
- if err := p.permissions.authorize(permissionReadTextFile); err != nil {
- return acpsdk.ReadTextFileResponse{}, err
- }
- resolvedPath, err := p.permissions.resolvePath(request.Path)
+ content, err := p.toolHostOrDefault().ReadTextFile(ctx, request.Path)
if err != nil {
return acpsdk.ReadTextFileResponse{}, err
}
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return acpsdk.ReadTextFileResponse{}, fmt.Errorf("acp: read %q: %w", resolvedPath, err)
- }
- return acpsdk.ReadTextFileResponse{Content: sliceLines(string(content), request.Line, request.Limit)}, nil
+ return acpsdk.ReadTextFileResponse{Content: sliceLines(content, request.Line, request.Limit)}, nil
}
func (p *AgentProcess) handleWriteTextFile(
- _ context.Context,
+ ctx context.Context,
request acpsdk.WriteTextFileRequest,
) (acpsdk.WriteTextFileResponse, error) {
- if err := p.permissions.authorize(permissionWriteTextFile); err != nil {
- return acpsdk.WriteTextFileResponse{}, err
- }
if p.isNetworkTurn() {
return acpsdk.WriteTextFileResponse{}, ErrToolBlockedForNetworkTurn
}
- resolvedPath, err := p.permissions.resolvePath(request.Path)
- if err != nil {
+ if err := p.toolHostOrDefault().WriteTextFile(ctx, request.Path, request.Content); err != nil {
return acpsdk.WriteTextFileResponse{}, err
}
- if err := os.MkdirAll(filepath.Dir(resolvedPath), 0o755); err != nil {
- return acpsdk.WriteTextFileResponse{}, fmt.Errorf(
- "acp: create parent directories for %q: %w",
- resolvedPath,
- err,
- )
- }
- if err := os.WriteFile(resolvedPath, []byte(request.Content), 0o600); err != nil {
- return acpsdk.WriteTextFileResponse{}, fmt.Errorf("acp: write %q: %w", resolvedPath, err)
- }
return acpsdk.WriteTextFileResponse{}, nil
}
@@ -279,7 +257,7 @@ func (p *AgentProcess) handleRequestPermission(
title = *request.ToolCall.Title
}
- decision, interactive := p.permissions.permissionDecision(request)
+ decision, interactive := p.toolHostOrDefault().PermissionDecision(request)
sessionID := string(request.SessionId)
toolCallID := strings.TrimSpace(string(request.ToolCall.ToolCallId))
@@ -383,11 +361,9 @@ func (p *AgentProcess) emitPermissionEvent(
}
func (p *AgentProcess) handleCreateTerminal(
+ ctx context.Context,
request acpsdk.CreateTerminalRequest,
) (acpsdk.CreateTerminalResponse, error) {
- if err := p.permissions.authorize(permissionCreateTerminal); err != nil {
- return acpsdk.CreateTerminalResponse{}, err
- }
ownership := terminalOwnership{}
if p.isNetworkTurn() {
argv, err := terminalArgv(request)
@@ -403,16 +379,29 @@ func (p *AgentProcess) handleCreateTerminal(
}
}
- cwd := p.Cwd
- if request.Cwd != nil {
- cwd = *request.Cwd
+ host := p.toolHostOrDefault()
+ if localHost, ok := host.(*localToolHost); ok {
+ return localHost.createTerminal(ctx, request, ownership)
}
- resolvedCwd, err := p.permissions.resolvePath(cwd)
+ response, err := host.CreateTerminal(ctx, request)
if err != nil {
return acpsdk.CreateTerminalResponse{}, err
}
+ p.recordTerminalOwnership(response.TerminalId, ownership)
+ return response, nil
+}
- return p.terminals.create(resolvedCwd, request, ownership)
+func (p *AgentProcess) recordTerminalOwnership(id string, ownership terminalOwnership) {
+ if strings.TrimSpace(id) == "" || !ownership.networkOwned {
+ return
+ }
+
+ p.terminalOwnershipMu.Lock()
+ defer p.terminalOwnershipMu.Unlock()
+ if p.terminalOwnership == nil {
+ p.terminalOwnership = make(map[string]terminalOwnership)
+ }
+ p.terminalOwnership[id] = ownership
}
func (p *AgentProcess) handleKillTerminal(
@@ -421,7 +410,7 @@ func (p *AgentProcess) handleKillTerminal(
if err := p.ensureNetworkTurnTerminalAccess(request.TerminalId, false); err != nil {
return acpsdk.KillTerminalCommandResponse{}, err
}
- if err := p.terminals.kill(request.TerminalId); err != nil {
+ if err := p.toolHostOrDefault().KillTerminal(request.TerminalId); err != nil {
return acpsdk.KillTerminalCommandResponse{}, err
}
return acpsdk.KillTerminalCommandResponse{}, nil
@@ -433,14 +422,24 @@ func (p *AgentProcess) handleTerminalOutput(
if err := p.ensureNetworkTurnTerminalAccess(request.TerminalId, true); err != nil {
return acpsdk.TerminalOutputResponse{}, err
}
- output, truncated, exitStatus, err := p.terminals.output(request.TerminalId)
+ host := p.toolHostOrDefault()
+ if localHost, ok := host.(*localToolHost); ok {
+ output, truncated, exitStatus, err := localHost.terminalOutputStatus(request.TerminalId)
+ if err != nil {
+ return acpsdk.TerminalOutputResponse{}, err
+ }
+ return acpsdk.TerminalOutputResponse{
+ Output: output,
+ Truncated: truncated,
+ ExitStatus: exitStatus,
+ }, nil
+ }
+ output, err := host.TerminalOutput(request.TerminalId)
if err != nil {
return acpsdk.TerminalOutputResponse{}, err
}
return acpsdk.TerminalOutputResponse{
- Output: output,
- Truncated: truncated,
- ExitStatus: exitStatus,
+ Output: output,
}, nil
}
@@ -451,16 +450,26 @@ func (p *AgentProcess) handleWaitForTerminalExit(
if err := p.ensureNetworkTurnTerminalAccess(request.TerminalId, true); err != nil {
return acpsdk.WaitForTerminalExitResponse{}, err
}
- exitStatus, err := p.terminals.wait(ctx, request.TerminalId)
+ host := p.toolHostOrDefault()
+ if localHost, ok := host.(*localToolHost); ok {
+ exitStatus, err := localHost.waitForTerminalExitStatus(ctx, request.TerminalId)
+ if err != nil {
+ return acpsdk.WaitForTerminalExitResponse{}, err
+ }
+ if exitStatus == nil {
+ return acpsdk.WaitForTerminalExitResponse{}, nil
+ }
+ return acpsdk.WaitForTerminalExitResponse{
+ ExitCode: exitStatus.ExitCode,
+ Signal: exitStatus.Signal,
+ }, nil
+ }
+ exitCode, err := host.WaitForTerminalExit(ctx, request.TerminalId)
if err != nil {
return acpsdk.WaitForTerminalExitResponse{}, err
}
- if exitStatus == nil {
- return acpsdk.WaitForTerminalExitResponse{}, nil
- }
return acpsdk.WaitForTerminalExitResponse{
- ExitCode: exitStatus.ExitCode,
- Signal: exitStatus.Signal,
+ ExitCode: acpsdk.Ptr(exitCode),
}, nil
}
@@ -470,12 +479,33 @@ func (p *AgentProcess) handleReleaseTerminal(
if err := p.ensureNetworkTurnTerminalAccess(request.TerminalId, false); err != nil {
return acpsdk.ReleaseTerminalResponse{}, err
}
- if err := p.terminals.release(request.TerminalId); err != nil {
+ if err := p.toolHostOrDefault().ReleaseTerminal(request.TerminalId); err != nil {
return acpsdk.ReleaseTerminalResponse{}, err
}
return acpsdk.ReleaseTerminalResponse{}, nil
}
+func (p *AgentProcess) toolHostOrDefault() ToolHost {
+ p.toolHostMu.Lock()
+ defer p.toolHostMu.Unlock()
+
+ if p.toolHost != nil {
+ return p.toolHost
+ }
+ procCtx := p.processCtx
+ if procCtx == nil {
+ procCtx = context.Background()
+ }
+ host := newLocalToolHostFromPolicy(procCtx, p.Cwd, p.permissions, slog.Default())
+ if p.terminals != nil {
+ host.terminals = p.terminals
+ } else {
+ p.terminals = host.terminals
+ }
+ p.toolHost = host
+ return host
+}
+
func newTerminalManager(ctx context.Context, logger *slog.Logger) *terminalManager {
return &terminalManager{
ctx: ctx,
@@ -816,19 +846,34 @@ func (p *AgentProcess) ensureNetworkTurnTerminalAccess(id string, requireSameTur
return nil
}
- term, err := p.terminals.lookup(id)
+ ownership, err := p.lookupTerminalOwnership(id)
if err != nil {
return err
}
- if !term.networkOwned {
+ if !ownership.networkOwned {
return ErrToolBlockedForNetworkTurn
}
- if requireSameTurn && strings.TrimSpace(term.ownerTurnID) != p.activeTurnID() {
+ if requireSameTurn && strings.TrimSpace(ownership.ownerTurnID) != p.activeTurnID() {
return ErrToolBlockedForNetworkTurn
}
return nil
}
+func (p *AgentProcess) lookupTerminalOwnership(id string) (terminalOwnership, error) {
+ host := p.toolHostOrDefault()
+ if localHost, ok := host.(*localToolHost); ok {
+ return localHost.terminalOwnership(id)
+ }
+
+ p.terminalOwnershipMu.RLock()
+ ownership, ok := p.terminalOwnership[id]
+ p.terminalOwnershipMu.RUnlock()
+ if !ok {
+ return terminalOwnership{}, ErrToolBlockedForNetworkTurn
+ }
+ return ownership, nil
+}
+
func mergeCommandEnv(base []string, variables []acpsdk.EnvVariable) []string {
merged := make(map[string]string, len(base)+len(variables))
order := make([]string, 0, len(base)+len(variables))
diff --git a/internal/acp/handlers_test.go b/internal/acp/handlers_test.go
index 250106868..0b1349eb9 100644
--- a/internal/acp/handlers_test.go
+++ b/internal/acp/handlers_test.go
@@ -235,7 +235,13 @@ func TestHandleCreateTerminalBlocksNonAllowlistedCommandsForNetworkTurn(t *testi
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if _, err := proc.handleCreateTerminal(tt.request); !errors.Is(err, ErrToolBlockedForNetworkTurn) {
+ if _, err := proc.handleCreateTerminal(
+ context.Background(),
+ tt.request,
+ ); !errors.Is(
+ err,
+ ErrToolBlockedForNetworkTurn,
+ ) {
t.Fatalf("handleCreateTerminal(%s) error = %v, want ErrToolBlockedForNetworkTurn", tt.name, err)
}
})
@@ -698,7 +704,7 @@ func TestNetworkTurnTerminalOwnershipGuards(t *testing.T) {
t.Fatalf("beginPrompt(first network) error = %v", err)
}
- networkCreate, err := proc.handleCreateTerminal(acpsdk.CreateTerminalRequest{
+ networkCreate, err := proc.handleCreateTerminal(context.Background(), acpsdk.CreateTerminalRequest{
SessionId: "sess-direct",
Command: "agh",
Args: []string{"network", "status"},
@@ -745,7 +751,7 @@ func TestNetworkTurnTerminalOwnershipGuards(t *testing.T) {
t.Fatalf("beginPrompt(user) error = %v", err)
}
- userCreate, err := proc.handleCreateTerminal(acpsdk.CreateTerminalRequest{
+ userCreate, err := proc.handleCreateTerminal(context.Background(), acpsdk.CreateTerminalRequest{
SessionId: "sess-direct",
Command: "sh",
Args: []string{"-c", "sleep 5"},
@@ -1121,6 +1127,81 @@ func TestPermissionHelperBranches(t *testing.T) {
}
}
+func TestHandleInboundCreateTerminalUsesRequestContext(t *testing.T) {
+ t.Parallel()
+
+ proc := newDirectProcess(t, aghconfig.PermissionModeApproveAll)
+ type contextKey string
+ const ctxKey contextKey = "terminal-create"
+ proc.toolHost = contextAwareToolHost{
+ createTerminalFn: func(ctx context.Context, _ acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error) {
+ if got, want := ctx.Value(ctxKey), "present"; got != want {
+ return acpsdk.CreateTerminalResponse{}, fmt.Errorf("ctx value = %v, want %q", got, want)
+ }
+ return acpsdk.CreateTerminalResponse{TerminalId: "term-ctx"}, nil
+ },
+ }
+
+ ctx := context.WithValue(context.Background(), ctxKey, "present")
+ result, reqErr := proc.handleInbound(
+ ctx,
+ acpsdk.ClientMethodTerminalCreate,
+ mustMarshalJSON(acpsdk.CreateTerminalRequest{
+ SessionId: "sess-direct",
+ Command: "sh",
+ Args: []string{"-c", "printf ready"},
+ Cwd: acpsdk.Ptr(proc.Cwd),
+ }),
+ )
+ if reqErr != nil {
+ t.Fatalf("handleInbound(create terminal) error = %v", reqErr)
+ }
+ response, ok := result.(acpsdk.CreateTerminalResponse)
+ if !ok {
+ t.Fatalf("handleInbound(create terminal) type = %T, want CreateTerminalResponse", result)
+ }
+ if response.TerminalId != "term-ctx" {
+ t.Fatalf("terminal id = %q, want %q", response.TerminalId, "term-ctx")
+ }
+}
+
+func TestToolHostOrDefaultUsesProcessLifecycleContext(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ policy, err := newPermissionPolicy(aghconfig.PermissionModeApproveAll, root)
+ if err != nil {
+ t.Fatalf("newPermissionPolicy() error = %v", err)
+ }
+
+ proc := &AgentProcess{
+ Cwd: root,
+ processCtx: ctx,
+ cancelProcess: cancel,
+ permissions: policy,
+ stderr: &lockedBuffer{},
+ done: make(chan struct{}),
+ StartedAt: timeNowUTC(),
+ SessionID: "sess-direct",
+ AgentName: "direct",
+ }
+
+ host, ok := proc.toolHostOrDefault().(*localToolHost)
+ if !ok {
+ t.Fatalf("toolHostOrDefault() type = %T, want *localToolHost", proc.toolHostOrDefault())
+ }
+
+ cancel()
+ select {
+ case <-host.terminals.ctx.Done():
+ case <-time.After(time.Second):
+ t.Fatal("terminal manager context did not close with process lifecycle")
+ }
+}
+
func newDirectProcess(t *testing.T, mode aghconfig.PermissionMode) *AgentProcess {
t.Helper()
@@ -1138,6 +1219,7 @@ func newDirectProcess(t *testing.T, mode aghconfig.PermissionMode) *AgentProcess
Cwd: root,
SessionID: "sess-direct",
StartedAt: timeNowUTC(),
+ processCtx: ctx,
permissions: policy,
terminals: newTerminalManager(ctx, slog.Default()),
done: make(chan struct{}),
@@ -1149,6 +1231,56 @@ func newDirectProcess(t *testing.T, mode aghconfig.PermissionMode) *AgentProcess
return proc
}
+type contextAwareToolHost struct {
+ createTerminalFn func(context.Context, acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error)
+}
+
+func (h contextAwareToolHost) ReadTextFile(context.Context, string) (string, error) {
+ return "", nil
+}
+
+func (h contextAwareToolHost) WriteTextFile(context.Context, string, string) error {
+ return nil
+}
+
+func (h contextAwareToolHost) ResolvePath(path string) (string, error) {
+ return path, nil
+}
+
+func (h contextAwareToolHost) Authorize(permissionOperation) error {
+ return nil
+}
+
+func (h contextAwareToolHost) PermissionDecision(acpsdk.RequestPermissionRequest) (permissionDecision, bool) {
+ return decisionAllowOnce, false
+}
+
+func (h contextAwareToolHost) CreateTerminal(
+ ctx context.Context,
+ req acpsdk.CreateTerminalRequest,
+) (acpsdk.CreateTerminalResponse, error) {
+ if h.createTerminalFn == nil {
+ return acpsdk.CreateTerminalResponse{}, nil
+ }
+ return h.createTerminalFn(ctx, req)
+}
+
+func (h contextAwareToolHost) KillTerminal(string) error {
+ return nil
+}
+
+func (h contextAwareToolHost) TerminalOutput(string) (string, error) {
+ return "", nil
+}
+
+func (h contextAwareToolHost) WaitForTerminalExit(context.Context, string) (int, error) {
+ return 0, nil
+}
+
+func (h contextAwareToolHost) ReleaseTerminal(string) error {
+ return nil
+}
+
func writeFakeAGHBinary(t *testing.T, dir string, body string) {
t.Helper()
diff --git a/internal/acp/launcher.go b/internal/acp/launcher.go
new file mode 100644
index 000000000..ecb9efb5a
--- /dev/null
+++ b/internal/acp/launcher.go
@@ -0,0 +1,143 @@
+package acp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "time"
+
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/subprocess"
+)
+
+// Launcher starts an ACP-capable agent process inside an environment.
+type Launcher = environment.Launcher
+
+// Handle represents a running agent process.
+type Handle = environment.Handle
+
+// LaunchSpec describes the ACP-capable command to start inside an environment.
+type LaunchSpec = environment.LaunchSpec
+
+var (
+ _ environment.Launcher = (*localLauncher)(nil)
+ _ environment.Handle = (*localProcessHandle)(nil)
+)
+
+type localLauncher struct {
+ logger *slog.Logger
+ stopTimeout time.Duration
+}
+
+type localProcessHandle struct {
+ process *subprocess.Process
+ cwd string
+}
+
+// NewLocalLauncher returns the local daemon-host subprocess launcher.
+func NewLocalLauncher(logger *slog.Logger, stopTimeout time.Duration) environment.Launcher {
+ return newLocalLauncher(logger, stopTimeout)
+}
+
+func newLocalLauncher(logger *slog.Logger, stopTimeout time.Duration) *localLauncher {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if stopTimeout <= 0 {
+ stopTimeout = defaultStopTimeout
+ }
+ return &localLauncher{
+ logger: logger,
+ stopTimeout: stopTimeout,
+ }
+}
+
+func (l *localLauncher) Launch(
+ ctx context.Context,
+ spec environment.LaunchSpec,
+) (environment.Handle, error) {
+ command, args, err := parseCommandString(spec.Command)
+ if err != nil {
+ return nil, err
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ managed, err := subprocess.Launch(ctx, subprocess.LaunchConfig{
+ Command: command,
+ Args: args,
+ Dir: spec.Cwd,
+ Env: daemonMatchedEnv(spec.Env),
+ Logger: l.logger,
+ DisableTransport: true,
+ ShutdownTimeout: l.stopTimeout,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("acp: start subprocess %q: %w", spec.Command, err)
+ }
+
+ return &localProcessHandle{
+ process: managed,
+ cwd: spec.Cwd,
+ }, nil
+}
+
+func (h *localProcessHandle) PID() int {
+ if h == nil || h.process == nil {
+ return 0
+ }
+ return h.process.PID()
+}
+
+func (h *localProcessHandle) Cwd() string {
+ if h == nil {
+ return ""
+ }
+ return h.cwd
+}
+
+func (h *localProcessHandle) Stdin() io.WriteCloser {
+ if h == nil || h.process == nil {
+ return nil
+ }
+ return h.process.Stdin()
+}
+
+func (h *localProcessHandle) Stdout() io.ReadCloser {
+ if h == nil || h.process == nil {
+ return nil
+ }
+ return h.process.Stdout()
+}
+
+func (h *localProcessHandle) Stderr() string {
+ if h == nil || h.process == nil {
+ return ""
+ }
+ return h.process.Stderr()
+}
+
+func (h *localProcessHandle) Done() <-chan struct{} {
+ if h == nil || h.process == nil {
+ done := make(chan struct{})
+ close(done)
+ return done
+ }
+ return h.process.Done()
+}
+
+func (h *localProcessHandle) Wait() error {
+ if h == nil || h.process == nil {
+ return nil
+ }
+ return h.process.Wait()
+}
+
+func (h *localProcessHandle) Stop(ctx context.Context) error {
+ if h == nil || h.process == nil {
+ return nil
+ }
+ return h.process.Shutdown(ctx)
+}
diff --git a/internal/acp/launcher_tool_host_test.go b/internal/acp/launcher_tool_host_test.go
new file mode 100644
index 000000000..158f99c60
--- /dev/null
+++ b/internal/acp/launcher_tool_host_test.go
@@ -0,0 +1,459 @@
+package acp
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestLocalLauncherLaunchProvidesWorkingPipes(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ launcher := newLocalLauncher(testDiscardLogger(), time.Second)
+ handle, err := launcher.Launch(testutil.Context(t), environment.LaunchSpec{
+ Command: "sh -c 'read line; printf \"%s\\n\" \"$line\"; sleep 0.1'",
+ Cwd: root,
+ Env: os.Environ(),
+ })
+ if err != nil {
+ t.Fatalf("Launch() error = %v", err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if stopErr := handle.Stop(cleanupCtx); stopErr != nil {
+ t.Fatalf("handle.Stop() cleanup error = %v", stopErr)
+ }
+ })
+ if handle.PID() <= 0 {
+ t.Fatalf("handle.PID() = %d, want positive pid", handle.PID())
+ }
+ if handle.Cwd() != root {
+ t.Fatalf("handle.Cwd() = %q, want %q", handle.Cwd(), root)
+ }
+
+ if _, err := handle.Stdin().Write([]byte("hello launcher\n")); err != nil {
+ t.Fatalf("handle.Stdin().Write() error = %v", err)
+ }
+ if err := handle.Stdin().Close(); err != nil {
+ t.Fatalf("handle.Stdin().Close() error = %v", err)
+ }
+ output := make([]byte, len("hello launcher\n"))
+ if _, err := io.ReadFull(handle.Stdout(), output); err != nil {
+ t.Fatalf("io.ReadFull(stdout) error = %v", err)
+ }
+ if got := string(output); got != "hello launcher\n" {
+ t.Fatalf("stdout = %q, want %q", got, "hello launcher\n")
+ }
+ if err := handle.Wait(); err != nil {
+ t.Fatalf("handle.Wait() error = %v", err)
+ }
+ select {
+ case <-handle.Done():
+ case <-time.After(time.Second):
+ t.Fatal("handle.Done() did not close after process exit")
+ }
+}
+
+func TestLocalConstructorsReturnInterfaceImplementations(t *testing.T) {
+ t.Parallel()
+
+ if launcher := NewLocalLauncher(nil, 0); launcher == nil {
+ t.Fatal("NewLocalLauncher() = nil, want launcher")
+ }
+
+ host, err := NewLocalToolHost(context.Background(), t.TempDir(), "", nil)
+ if err != nil {
+ t.Fatalf("NewLocalToolHost() error = %v", err)
+ }
+ localHost, ok := host.(*localToolHost)
+ if !ok {
+ t.Fatalf("NewLocalToolHost() type = %T, want *localToolHost", host)
+ }
+ if localHost.terminals == nil {
+ t.Fatal("NewLocalToolHost() terminals = nil, want terminal manager")
+ }
+ localHost.Close()
+}
+
+func TestLocalLauncherLaunchInvalidCommandReturnsError(t *testing.T) {
+ t.Parallel()
+
+ launcher := newLocalLauncher(testDiscardLogger(), time.Second)
+ if _, err := launcher.Launch(testutil.Context(t), environment.LaunchSpec{
+ Command: "definitely-not-an-agh-test-command",
+ Cwd: t.TempDir(),
+ }); err == nil {
+ t.Fatal("Launch(invalid command) error = nil, want non-nil")
+ }
+}
+
+func TestLocalLauncherLaunchHonorsCanceledContext(t *testing.T) {
+ t.Parallel()
+
+ launcher := newLocalLauncher(testDiscardLogger(), time.Second)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := launcher.Launch(ctx, environment.LaunchSpec{
+ Command: "sh -c 'sleep 1'",
+ Cwd: t.TempDir(),
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("Launch(canceled context) error = %v, want context canceled", err)
+ }
+}
+
+func TestLocalProcessHandleStopTerminatesProcess(t *testing.T) {
+ t.Parallel()
+
+ launcher := newLocalLauncher(testDiscardLogger(), 10*time.Millisecond)
+ handle, err := launcher.Launch(testutil.Context(t), environment.LaunchSpec{
+ Command: "sh -c 'while :; do sleep 1; done'",
+ Cwd: t.TempDir(),
+ Env: os.Environ(),
+ })
+ if err != nil {
+ t.Fatalf("Launch(long-running) error = %v", err)
+ }
+
+ stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := handle.Stop(stopCtx); err != nil {
+ t.Fatalf("handle.Stop() error = %v", err)
+ }
+ select {
+ case <-handle.Done():
+ case <-time.After(time.Second):
+ t.Fatal("handle.Done() did not close after Stop")
+ }
+ if err := handle.Wait(); err != nil {
+ t.Fatalf("handle.Wait() after Stop error = %v", err)
+ }
+}
+
+func TestLocalToolHostReadTextFile(t *testing.T) {
+ t.Parallel()
+
+ host, root := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ target := filepath.Join(root, "notes.txt")
+ if err := os.WriteFile(target, []byte("from disk"), 0o644); err != nil {
+ t.Fatalf("os.WriteFile() error = %v", err)
+ }
+
+ content, err := host.ReadTextFile(testutil.Context(t), "notes.txt")
+ if err != nil {
+ t.Fatalf("ReadTextFile() error = %v", err)
+ }
+ if content != "from disk" {
+ t.Fatalf("ReadTextFile() = %q, want %q", content, "from disk")
+ }
+
+ if _, err := host.ReadTextFile(testutil.Context(t), "missing.txt"); err == nil {
+ t.Fatal("ReadTextFile(missing) error = nil, want non-nil")
+ }
+}
+
+func TestLocalToolHostWriteTextFile(t *testing.T) {
+ t.Parallel()
+
+ host, root := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ if err := host.WriteTextFile(testutil.Context(t), "nested/notes.txt", "saved"); err != nil {
+ t.Fatalf("WriteTextFile() error = %v", err)
+ }
+
+ target := filepath.Join(root, "nested", "notes.txt")
+ content, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatalf("os.ReadFile(%q) error = %v", target, err)
+ }
+ if string(content) != "saved" {
+ t.Fatalf("written content = %q, want %q", content, "saved")
+ }
+ info, err := os.Stat(target)
+ if err != nil {
+ t.Fatalf("os.Stat(%q) error = %v", target, err)
+ }
+ if got := info.Mode().Perm(); got != 0o600 {
+ t.Fatalf("written mode = %v, want 0600", got)
+ }
+}
+
+func TestLocalToolHostResolvePath(t *testing.T) {
+ t.Parallel()
+
+ host, root := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ resolved, err := host.ResolvePath("inside.txt")
+ if err != nil {
+ t.Fatalf("ResolvePath(relative) error = %v", err)
+ }
+ if want := filepath.Join(mustCanonicalDir(t, root), "inside.txt"); resolved != want {
+ t.Fatalf("ResolvePath(relative) = %q, want %q", resolved, want)
+ }
+
+ if _, err := host.ResolvePath(filepath.Join(root, "..", "escape.txt")); !errors.Is(err, ErrPathOutsideWorkspace) {
+ t.Fatalf("ResolvePath(outside) error = %v, want ErrPathOutsideWorkspace", err)
+ }
+}
+
+func TestLocalToolHostAuthorize(t *testing.T) {
+ t.Parallel()
+
+ approveAll, _ := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ for _, op := range []environment.PermissionOperation{
+ environment.PermissionOperationReadTextFile,
+ environment.PermissionOperationWriteTextFile,
+ environment.PermissionOperationCreateTerminal,
+ environment.PermissionOperationRequestToolGrant,
+ } {
+ if err := approveAll.Authorize(op); err != nil {
+ t.Fatalf("Authorize(%s) with approve-all error = %v", op, err)
+ }
+ }
+
+ denyAll, _ := newTestLocalToolHost(t, aghconfig.PermissionModeDenyAll)
+ for _, op := range []environment.PermissionOperation{
+ environment.PermissionOperationReadTextFile,
+ environment.PermissionOperationWriteTextFile,
+ environment.PermissionOperationCreateTerminal,
+ environment.PermissionOperationRequestToolGrant,
+ } {
+ if err := denyAll.Authorize(op); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("Authorize(%s) with deny-all error = %v, want ErrPermissionDenied", op, err)
+ }
+ }
+}
+
+func TestLocalToolHostCreateTerminalUsesResolvedCwd(t *testing.T) {
+ t.Parallel()
+
+ host, root := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ cwd := filepath.Join(root, "work")
+ if err := os.MkdirAll(cwd, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll(%q) error = %v", cwd, err)
+ }
+
+ response, err := host.CreateTerminal(testutil.Context(t), acpsdk.CreateTerminalRequest{
+ SessionId: "sess-terminal",
+ Command: "pwd",
+ Cwd: acpsdk.Ptr(cwd),
+ })
+ if err != nil {
+ t.Fatalf("CreateTerminal() error = %v", err)
+ }
+ if _, err := host.WaitForTerminalExit(testutil.Context(t), response.TerminalId); err != nil {
+ t.Fatalf("WaitForTerminalExit() error = %v", err)
+ }
+ output, err := host.TerminalOutput(response.TerminalId)
+ if err != nil {
+ t.Fatalf("TerminalOutput() error = %v", err)
+ }
+ if got, want := strings.TrimSpace(output), mustCanonicalDir(t, cwd); got != want {
+ t.Fatalf("terminal cwd output = %q, want %q", got, want)
+ }
+}
+
+func TestDriverUsesInjectedLauncherAndToolHostOptions(t *testing.T) {
+ t.Parallel()
+
+ launcher := &recordingLauncher{delegate: newLocalLauncher(testDiscardLogger(), time.Second)}
+ toolHost, _ := newTestLocalToolHost(t, aghconfig.PermissionModeApproveAll)
+ driver := New(WithLauncher(launcher), WithToolHost(toolHost))
+
+ if driver.launcher != launcher {
+ t.Fatal("WithLauncher() did not apply")
+ }
+ if driver.toolHost != toolHost {
+ t.Fatal("WithToolHost() did not apply")
+ }
+}
+
+func TestDriverStartUsesInjectedLauncher(t *testing.T) {
+ t.Parallel()
+
+ handle := newFakeHandle(t.TempDir())
+ launcher := &recordingLauncher{handle: handle}
+ driver := New(WithLogger(testDiscardLogger()), WithLauncher(launcher))
+ proc, err := driver.launchAgentProcess(testutil.Context(t), StartOpts{
+ AgentName: "helper",
+ Command: "sh -c 'cat'",
+ Cwd: t.TempDir(),
+ Permissions: aghconfig.PermissionModeApproveAll,
+ })
+ if err != nil {
+ t.Fatalf("launchAgentProcess() error = %v", err)
+ }
+ handle.finish()
+ select {
+ case <-proc.Done():
+ case <-time.After(time.Second):
+ t.Fatal("process Done() did not close for fake handle")
+ }
+
+ spec, ok := launcher.lastSpec()
+ if !ok {
+ t.Fatal("launchAgentProcess() did not call injected launcher")
+ }
+ if spec.Command != "sh -c 'cat'" {
+ t.Fatalf("launcher command = %q, want %q", spec.Command, "sh -c 'cat'")
+ }
+}
+
+func TestDriverLaunchAgentProcessWrapsLauncherErrors(t *testing.T) {
+ t.Parallel()
+
+ launchErr := errors.New("launch failed")
+ driver := New(WithLogger(testDiscardLogger()), WithLauncher(&recordingLauncher{err: launchErr}))
+ _, err := driver.launchAgentProcess(testutil.Context(t), StartOpts{
+ AgentName: "helper",
+ Command: "sh -c 'cat'",
+ Cwd: t.TempDir(),
+ Permissions: aghconfig.PermissionModeApproveAll,
+ })
+ if err == nil {
+ t.Fatal("launchAgentProcess() error = nil, want non-nil")
+ }
+ if !errors.Is(err, launchErr) {
+ t.Fatalf("launchAgentProcess() error = %v, want wrapped launch error", err)
+ }
+ if !strings.Contains(err.Error(), `helper`) || !strings.Contains(err.Error(), `sh -c 'cat'`) {
+ t.Fatalf("launchAgentProcess() error = %v, want agent and command context", err)
+ }
+}
+
+type recordingLauncher struct {
+ delegate environment.Launcher
+ handle environment.Handle
+ err error
+
+ mu sync.Mutex
+ called bool
+ spec environment.LaunchSpec
+}
+
+func (l *recordingLauncher) Launch(
+ ctx context.Context,
+ spec environment.LaunchSpec,
+) (environment.Handle, error) {
+ l.mu.Lock()
+ l.called = true
+ l.spec = spec
+ l.mu.Unlock()
+ if l.handle != nil {
+ return l.handle, nil
+ }
+ if l.err != nil {
+ return nil, l.err
+ }
+ return l.delegate.Launch(ctx, spec)
+}
+
+func (l *recordingLauncher) lastSpec() (environment.LaunchSpec, bool) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.spec, l.called
+}
+
+type fakeHandle struct {
+ cwd string
+ stdoutReader *io.PipeReader
+ stdoutWriter *io.PipeWriter
+ done chan struct{}
+ finishOnce sync.Once
+}
+
+type noopWriteCloser struct{}
+
+func newFakeHandle(cwd string) *fakeHandle {
+ stdoutReader, stdoutWriter := io.Pipe()
+ return &fakeHandle{
+ cwd: cwd,
+ stdoutReader: stdoutReader,
+ stdoutWriter: stdoutWriter,
+ done: make(chan struct{}),
+ }
+}
+
+func (h *fakeHandle) PID() int {
+ return 123
+}
+
+func (h *fakeHandle) Cwd() string {
+ return h.cwd
+}
+
+func (h *fakeHandle) Stdin() io.WriteCloser {
+ return noopWriteCloser{}
+}
+
+func (h *fakeHandle) Stdout() io.ReadCloser {
+ return h.stdoutReader
+}
+
+func (h *fakeHandle) Stderr() string {
+ return ""
+}
+
+func (h *fakeHandle) Done() <-chan struct{} {
+ return h.done
+}
+
+func (h *fakeHandle) Wait() error {
+ <-h.done
+ return nil
+}
+
+func (h *fakeHandle) Stop(context.Context) error {
+ h.finish()
+ return nil
+}
+
+func (h *fakeHandle) finish() {
+ h.finishOnce.Do(func() {
+ _ = h.stdoutWriter.Close()
+ close(h.done)
+ })
+}
+
+func (noopWriteCloser) Write(p []byte) (int, error) {
+ return len(p), nil
+}
+
+func (noopWriteCloser) Close() error {
+ return nil
+}
+
+func newTestLocalToolHost(
+ t *testing.T,
+ mode aghconfig.PermissionMode,
+) (*localToolHost, string) {
+ t.Helper()
+
+ root := t.TempDir()
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ host, err := newLocalToolHost(ctx, root, mode, testDiscardLogger())
+ if err != nil {
+ t.Fatalf("newLocalToolHost() error = %v", err)
+ }
+ t.Cleanup(host.Close)
+ return host, root
+}
+
+func testDiscardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
diff --git a/internal/acp/permission.go b/internal/acp/permission.go
index 7abb39a78..2f4b094a1 100644
--- a/internal/acp/permission.go
+++ b/internal/acp/permission.go
@@ -26,25 +26,6 @@ var (
ErrPendingPermissionConflict = errors.New("acp: pending permission lookup is ambiguous")
)
-type permissionOperation string
-
-const (
- permissionReadTextFile permissionOperation = "fs/read_text_file"
- permissionWriteTextFile permissionOperation = "fs/write_text_file"
- permissionCreateTerminal permissionOperation = "terminal/create"
- permissionRequestToolGrant permissionOperation = "session/request_permission"
-)
-
-type permissionDecision string
-
-const (
- decisionPending permissionDecision = "pending"
- decisionAllowOnce permissionDecision = "allow-once"
- decisionAllowAlways permissionDecision = "allow-always"
- decisionRejectOnce permissionDecision = "reject-once"
- decisionRejectAlways permissionDecision = "reject-always"
-)
-
type permissionPolicy struct {
mode aghconfig.PermissionMode
root string
diff --git a/internal/acp/tool_host.go b/internal/acp/tool_host.go
new file mode 100644
index 000000000..df0fb1707
--- /dev/null
+++ b/internal/acp/tool_host.go
@@ -0,0 +1,212 @@
+package acp
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+// ToolHost abstracts ACP file, permission, and terminal operations for a runtime.
+type ToolHost = environment.ToolHost
+
+type permissionOperation = environment.PermissionOperation
+
+const (
+ permissionReadTextFile = environment.PermissionOperationReadTextFile
+ permissionWriteTextFile = environment.PermissionOperationWriteTextFile
+ permissionCreateTerminal = environment.PermissionOperationCreateTerminal
+ permissionRequestToolGrant = environment.PermissionOperationRequestToolGrant
+)
+
+type permissionDecision = environment.PermissionDecision
+
+const (
+ decisionPending = environment.PermissionDecisionPending
+ decisionAllowOnce = environment.PermissionDecisionAllowOnce
+ decisionAllowAlways = environment.PermissionDecisionAllowAlways
+ decisionRejectOnce = environment.PermissionDecisionRejectOnce
+ decisionRejectAlways = environment.PermissionDecisionRejectAlways
+)
+
+var _ environment.ToolHost = (*localToolHost)(nil)
+
+type localToolHost struct {
+ cwd string
+ permissions permissionPolicy
+ terminals *terminalManager
+}
+
+// NewLocalToolHost returns the local daemon-host file, permission, and terminal host.
+func NewLocalToolHost(
+ ctx context.Context,
+ root string,
+ mode aghconfig.PermissionMode,
+ logger *slog.Logger,
+) (environment.ToolHost, error) {
+ return newLocalToolHost(ctx, root, mode, logger)
+}
+
+func newLocalToolHost(
+ ctx context.Context,
+ root string,
+ mode aghconfig.PermissionMode,
+ logger *slog.Logger,
+) (*localToolHost, error) {
+ policy, err := newPermissionPolicy(mode, root)
+ if err != nil {
+ return nil, err
+ }
+ return newLocalToolHostFromPolicy(ctx, root, policy, logger), nil
+}
+
+func newLocalToolHostFromPolicy(
+ ctx context.Context,
+ root string,
+ policy permissionPolicy,
+ logger *slog.Logger,
+) *localToolHost {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &localToolHost{
+ cwd: root,
+ permissions: policy,
+ terminals: newTerminalManager(ctx, logger),
+ }
+}
+
+func (h *localToolHost) ReadTextFile(_ context.Context, path string) (string, error) {
+ if err := h.Authorize(permissionReadTextFile); err != nil {
+ return "", err
+ }
+ resolvedPath, err := h.ResolvePath(path)
+ if err != nil {
+ return "", err
+ }
+ content, err := os.ReadFile(resolvedPath)
+ if err != nil {
+ return "", fmt.Errorf("acp: read %q: %w", resolvedPath, err)
+ }
+ return string(content), nil
+}
+
+func (h *localToolHost) WriteTextFile(_ context.Context, path string, content string) error {
+ if err := h.Authorize(permissionWriteTextFile); err != nil {
+ return err
+ }
+ resolvedPath, err := h.ResolvePath(path)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(resolvedPath), 0o755); err != nil {
+ return fmt.Errorf("acp: create parent directories for %q: %w", resolvedPath, err)
+ }
+ if err := os.WriteFile(resolvedPath, []byte(content), 0o600); err != nil {
+ return fmt.Errorf("acp: write %q: %w", resolvedPath, err)
+ }
+ return nil
+}
+
+func (h *localToolHost) ResolvePath(path string) (string, error) {
+ return h.permissions.resolvePath(path)
+}
+
+func (h *localToolHost) Authorize(op environment.PermissionOperation) error {
+ return h.permissions.authorize(op)
+}
+
+func (h *localToolHost) PermissionDecision(
+ req acpsdk.RequestPermissionRequest,
+) (environment.PermissionDecision, bool) {
+ return h.permissions.permissionDecision(req)
+}
+
+func (h *localToolHost) CreateTerminal(
+ ctx context.Context,
+ req acpsdk.CreateTerminalRequest,
+) (acpsdk.CreateTerminalResponse, error) {
+ return h.createTerminal(ctx, req, terminalOwnership{})
+}
+
+func (h *localToolHost) createTerminal(
+ _ context.Context,
+ req acpsdk.CreateTerminalRequest,
+ ownership terminalOwnership,
+) (acpsdk.CreateTerminalResponse, error) {
+ if err := h.Authorize(permissionCreateTerminal); err != nil {
+ return acpsdk.CreateTerminalResponse{}, err
+ }
+ cwd := h.cwd
+ if req.Cwd != nil {
+ cwd = *req.Cwd
+ }
+ resolvedCwd, err := h.ResolvePath(cwd)
+ if err != nil {
+ return acpsdk.CreateTerminalResponse{}, err
+ }
+ return h.terminals.create(resolvedCwd, req, ownership)
+}
+
+func (h *localToolHost) KillTerminal(id string) error {
+ return h.terminals.kill(id)
+}
+
+func (h *localToolHost) TerminalOutput(id string) (string, error) {
+ output, _, _, err := h.terminalOutputStatus(id)
+ return output, err
+}
+
+func (h *localToolHost) terminalOutputStatus(
+ id string,
+) (string, bool, *acpsdk.TerminalExitStatus, error) {
+ return h.terminals.output(id)
+}
+
+func (h *localToolHost) WaitForTerminalExit(ctx context.Context, id string) (int, error) {
+ exitStatus, err := h.waitForTerminalExitStatus(ctx, id)
+ if err != nil {
+ return 0, err
+ }
+ if exitStatus == nil || exitStatus.ExitCode == nil {
+ return 0, nil
+ }
+ return *exitStatus.ExitCode, nil
+}
+
+func (h *localToolHost) waitForTerminalExitStatus(
+ ctx context.Context,
+ id string,
+) (*acpsdk.TerminalExitStatus, error) {
+ return h.terminals.wait(ctx, id)
+}
+
+func (h *localToolHost) ReleaseTerminal(id string) error {
+ return h.terminals.release(id)
+}
+
+func (h *localToolHost) terminalOwnership(id string) (terminalOwnership, error) {
+ term, err := h.terminals.lookup(id)
+ if err != nil {
+ return terminalOwnership{}, err
+ }
+ return terminalOwnership{
+ networkOwned: term.networkOwned,
+ ownerTurnID: term.ownerTurnID,
+ }, nil
+}
+
+func (h *localToolHost) Close() {
+ if h == nil || h.terminals == nil {
+ return
+ }
+ h.terminals.closeAll()
+}
diff --git a/internal/acp/types.go b/internal/acp/types.go
index 8fa3fee75..01b285734 100644
--- a/internal/acp/types.go
+++ b/internal/acp/types.go
@@ -14,6 +14,7 @@ import (
acpsdk "github.com/coder/acp-go-sdk"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/subprocess"
)
@@ -53,6 +54,8 @@ type StartOpts struct {
Permissions aghconfig.PermissionMode
SystemPrompt string
ResumeSessionID string
+ Launcher environment.Launcher
+ ToolHost environment.ToolHost
}
// Validate ensures the start options are minimally usable.
@@ -200,13 +203,20 @@ type AgentProcess struct {
StartedAt time.Time
managed *subprocess.Process
+ handle environment.Handle
+ toolHostMu sync.Mutex
+ toolHost environment.ToolHost
cmd *exec.Cmd
conn *acpsdk.Connection
stderr *lockedBuffer
+ processCtx context.Context
cancelProcess context.CancelFunc
permissions permissionPolicy
terminals *terminalManager
+ terminalOwnershipMu sync.RWMutex
+ terminalOwnership map[string]terminalOwnership
+
waitMu sync.RWMutex
waitErr error
done chan struct{}
@@ -267,6 +277,9 @@ func (p *AgentProcess) Wait() error {
// Stderr returns the currently captured stderr output for the subprocess.
func (p *AgentProcess) Stderr() string {
+ if p.handle != nil {
+ return p.handle.Stderr()
+ }
if p.managed != nil {
return p.managed.Stderr()
}
@@ -276,6 +289,16 @@ func (p *AgentProcess) Stderr() string {
return p.stderr.String()
}
+// ToolHost returns the environment-owned tool host used by this process.
+func (p *AgentProcess) ToolHost() environment.ToolHost {
+ if p == nil {
+ return nil
+ }
+ p.toolHostMu.Lock()
+ defer p.toolHostMu.Unlock()
+ return p.toolHost
+}
+
func (p *AgentProcess) setWaitError(err error) {
p.waitMu.Lock()
defer p.waitMu.Unlock()
diff --git a/internal/api/contract/contract.go b/internal/api/contract/contract.go
index 39459b9a6..3fada38c4 100644
--- a/internal/api/contract/contract.go
+++ b/internal/api/contract/contract.go
@@ -38,11 +38,23 @@ type SessionPayload struct {
// StopReason is the session-level stop classification, distinct from AgentEventPayload.StopReason.
StopReason store.StopReason `json:"stop_reason,omitempty"`
// StopDetail is the session-level stop context paired with StopReason.
- StopDetail string `json:"stop_detail,omitempty"`
- ACPSessionID string `json:"acp_session_id,omitempty"`
- ACPCaps *ACPCapsPayload `json:"acp_caps,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ StopDetail string `json:"stop_detail,omitempty"`
+ ACPSessionID string `json:"acp_session_id,omitempty"`
+ ACPCaps *ACPCapsPayload `json:"acp_caps,omitempty"`
+ Environment *SessionEnvironmentPayload `json:"environment,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// SessionEnvironmentPayload is the shared session environment response payload.
+type SessionEnvironmentPayload struct {
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ State string `json:"state,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ LastSyncError string `json:"last_sync_error,omitempty"`
+ ProviderStateJSON json.RawMessage `json:"provider_state_json,omitempty"`
}
// ACPCapsPayload is the JSON representation of ACP capabilities.
@@ -480,17 +492,19 @@ type MemoryHealthPayload struct {
// CreateWorkspaceRequest is the shared workspace creation request payload.
type CreateWorkspaceRequest struct {
- RootDir string `json:"root_dir"`
- Name string `json:"name,omitempty"`
- AddDirs []string `json:"add_dirs,omitempty"`
- DefaultAgent string `json:"default_agent,omitempty"`
+ RootDir string `json:"root_dir"`
+ Name string `json:"name,omitempty"`
+ AddDirs []string `json:"add_dirs,omitempty"`
+ DefaultAgent string `json:"default_agent,omitempty"`
+ EnvironmentRef string `json:"environment_ref,omitempty"`
}
// UpdateWorkspaceRequest is the shared workspace update request payload.
type UpdateWorkspaceRequest struct {
- Name *string `json:"name"`
- AddDirs *[]string `json:"add_dirs"`
- DefaultAgent *string `json:"default_agent"`
+ Name *string `json:"name"`
+ AddDirs *[]string `json:"add_dirs"`
+ DefaultAgent *string `json:"default_agent"`
+ EnvironmentRef *string `json:"environment_ref"`
}
// ResolveWorkspaceRequest is the shared workspace resolve request payload.
@@ -500,13 +514,14 @@ type ResolveWorkspaceRequest struct {
// WorkspacePayload is the shared workspace response payload.
type WorkspacePayload struct {
- ID string `json:"id"`
- RootDir string `json:"root_dir"`
- AddDirs []string `json:"add_dirs"`
- Name string `json:"name"`
- DefaultAgent string `json:"default_agent,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ RootDir string `json:"root_dir"`
+ AddDirs []string `json:"add_dirs"`
+ Name string `json:"name"`
+ DefaultAgent string `json:"default_agent,omitempty"`
+ EnvironmentRef string `json:"environment_ref,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// WorkspaceSkillPayload is the shared workspace skill response payload.
diff --git a/internal/api/contract/contract_test.go b/internal/api/contract/contract_test.go
index 2dafea6f4..f01657001 100644
--- a/internal/api/contract/contract_test.go
+++ b/internal/api/contract/contract_test.go
@@ -27,8 +27,15 @@ func TestSessionPayloadJSONShape(t *testing.T) {
Workspace: "/workspace",
State: session.StateActive,
ACPSessionID: "acp-123",
- CreatedAt: now,
- UpdatedAt: now,
+ Environment: &store.SessionEnvironmentMeta{
+ EnvironmentID: "env-json",
+ Backend: "local",
+ Profile: "local",
+ State: "prepared",
+ InstanceID: "instance-json",
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
ACPCaps: acp.Caps{
SupportsLoadSession: true,
SupportedModes: []string{"chat"},
@@ -58,6 +65,15 @@ func TestSessionPayloadJSONShape(t *testing.T) {
if acpCaps["supports_load_session"] != true {
t.Fatalf("acp_caps JSON = %#v", acpCaps)
}
+ environmentPayload, ok := got["environment"].(map[string]any)
+ if !ok {
+ t.Fatalf("environment type = %T, want object", got["environment"])
+ }
+ if environmentPayload["environment_id"] != "env-json" ||
+ environmentPayload["backend"] != "local" ||
+ environmentPayload["instance_id"] != "instance-json" {
+ t.Fatalf("environment JSON = %#v", environmentPayload)
+ }
})
}
@@ -120,6 +136,47 @@ func TestWorkspacePayloadPreservesOmitEmptyBehavior(t *testing.T) {
})
}
+func TestWorkspaceEnvironmentRefJSONFields(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Should serialize create workspace environment_ref", func(t *testing.T) {
+ t.Parallel()
+
+ payload := contract.CreateWorkspaceRequest{
+ RootDir: "/workspace",
+ EnvironmentRef: "daytona-dev",
+ }
+
+ var got map[string]any
+ marshalJSON(t, payload, &got)
+
+ if got["environment_ref"] != "daytona-dev" {
+ t.Fatalf("environment_ref = %#v, want daytona-dev", got["environment_ref"])
+ }
+ })
+
+ t.Run("Should include workspace payload environment_ref", func(t *testing.T) {
+ t.Parallel()
+
+ payload := contract.WorkspacePayload{
+ ID: "ws_alpha",
+ RootDir: "/workspace",
+ AddDirs: []string{},
+ Name: "alpha",
+ EnvironmentRef: "daytona-dev",
+ CreatedAt: time.Date(2026, 4, 7, 10, 30, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 7, 11, 30, 0, 0, time.UTC),
+ }
+
+ var got map[string]any
+ marshalJSON(t, payload, &got)
+
+ if got["environment_ref"] != "daytona-dev" {
+ t.Fatalf("environment_ref = %#v, want daytona-dev", got["environment_ref"])
+ }
+ })
+}
+
func TestAgentEventPayloadRoundTripsThroughJSON(t *testing.T) {
t.Parallel()
diff --git a/internal/api/contract/resources.go b/internal/api/contract/resources.go
new file mode 100644
index 000000000..e0ac16c43
--- /dev/null
+++ b/internal/api/contract/resources.go
@@ -0,0 +1,33 @@
+package contract
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+// PutResourceRequest is the shared desired-state upsert payload.
+type PutResourceRequest struct {
+ Scope resources.ResourceScope `json:"scope"`
+ ExpectedVersion int64 `json:"expected_version,omitempty"`
+ Spec json.RawMessage `json:"spec"`
+}
+
+// DeleteResourceRequest is the shared desired-state delete payload.
+type DeleteResourceRequest struct {
+ ExpectedVersion int64 `json:"expected_version"`
+}
+
+// ResourceRecordPayload is the shared desired-state record response shape.
+type ResourceRecordPayload struct {
+ Kind resources.ResourceKind `json:"kind"`
+ ID string `json:"id"`
+ Version int64 `json:"version"`
+ Scope resources.ResourceScope `json:"scope"`
+ Owner resources.ResourceOwner `json:"owner"`
+ Source resources.ResourceSource `json:"source"`
+ Spec json.RawMessage `json:"spec"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/internal/api/contract/responses.go b/internal/api/contract/responses.go
index 605f4967e..28e839a53 100644
--- a/internal/api/contract/responses.go
+++ b/internal/api/contract/responses.go
@@ -210,3 +210,13 @@ type ExtensionsResponse struct {
type ExtensionResponse struct {
Extension ExtensionPayload `json:"extension"`
}
+
+// ResourcesResponse wraps the shared desired-state resource list payload.
+type ResourcesResponse struct {
+ Records []ResourceRecordPayload `json:"records"`
+}
+
+// ResourceResponse wraps one desired-state resource payload.
+type ResourceResponse struct {
+ Record ResourceRecordPayload `json:"record"`
+}
diff --git a/internal/api/core/conversions.go b/internal/api/core/conversions.go
index aa85ac400..58709734f 100644
--- a/internal/api/core/conversions.go
+++ b/internal/api/core/conversions.go
@@ -45,9 +45,27 @@ func SessionPayloadFromInfo(info *session.Info) contract.SessionPayload {
if caps := ACPCapsPayloadFromInfo(info.ACPCaps); caps != nil {
payload.ACPCaps = caps
}
+ if environment := SessionEnvironmentPayloadFromMeta(info.Environment); environment != nil {
+ payload.Environment = environment
+ }
return payload
}
+// SessionEnvironmentPayloadFromMeta converts session environment metadata into the shared payload.
+func SessionEnvironmentPayloadFromMeta(meta *store.SessionEnvironmentMeta) *contract.SessionEnvironmentPayload {
+ if meta == nil {
+ return nil
+ }
+ return &contract.SessionEnvironmentPayload{
+ EnvironmentID: strings.TrimSpace(meta.EnvironmentID),
+ Backend: strings.TrimSpace(meta.Backend),
+ Profile: strings.TrimSpace(meta.Profile),
+ State: strings.TrimSpace(meta.State),
+ InstanceID: strings.TrimSpace(meta.InstanceID),
+ LastSyncError: strings.TrimSpace(meta.LastSyncError),
+ }
+}
+
// SessionPayloadsFromInfos converts a session list into response payloads.
func SessionPayloadsFromInfos(infos []*session.Info) []contract.SessionPayload {
payload := make([]contract.SessionPayload, 0, len(infos))
@@ -437,13 +455,14 @@ func WorkspacePayloadFromWorkspace(workspace workspacepkg.Workspace) contract.Wo
addDirs = append(addDirs, workspace.AdditionalDirs...)
return contract.WorkspacePayload{
- ID: workspace.ID,
- RootDir: workspace.RootDir,
- AddDirs: addDirs,
- Name: workspace.Name,
- DefaultAgent: workspace.DefaultAgent,
- CreatedAt: workspace.CreatedAt,
- UpdatedAt: workspace.UpdatedAt,
+ ID: workspace.ID,
+ RootDir: workspace.RootDir,
+ AddDirs: addDirs,
+ Name: workspace.Name,
+ DefaultAgent: workspace.DefaultAgent,
+ EnvironmentRef: workspace.EnvironmentRef,
+ CreatedAt: workspace.CreatedAt,
+ UpdatedAt: workspace.UpdatedAt,
}
}
diff --git a/internal/api/core/conversions_parsers_test.go b/internal/api/core/conversions_parsers_test.go
index 715ad2f24..44e102ce3 100644
--- a/internal/api/core/conversions_parsers_test.go
+++ b/internal/api/core/conversions_parsers_test.go
@@ -33,8 +33,17 @@ func TestSessionPayloadFromInfo(t *testing.T) {
StopReason: store.StopTimeout,
StopDetail: "deadline exceeded",
ACPSessionID: "acp-123",
- CreatedAt: now,
- UpdatedAt: now,
+ Environment: &store.SessionEnvironmentMeta{
+ EnvironmentID: "env-1",
+ Backend: "local",
+ Profile: "local",
+ State: "prepared",
+ InstanceID: "instance-1",
+ ProviderState: json.RawMessage(`{"sandbox_id":"sb-123","token":"secret"}`),
+ LastSyncError: "sync failed",
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
ACPCaps: acp.Caps{
SupportsLoadSession: true,
SupportedModes: []string{"chat"},
@@ -55,6 +64,17 @@ func TestSessionPayloadFromInfo(t *testing.T) {
if payload.ACPCaps == nil || !payload.ACPCaps.SupportsLoadSession || len(payload.ACPCaps.SupportedModels) != 1 {
t.Fatalf("caps = %#v", payload.ACPCaps)
}
+ if payload.Environment == nil || payload.Environment.EnvironmentID != "env-1" ||
+ payload.Environment.Backend != "local" ||
+ payload.Environment.Profile != "local" ||
+ payload.Environment.State != "prepared" ||
+ payload.Environment.InstanceID != "instance-1" ||
+ payload.Environment.LastSyncError != "sync failed" {
+ t.Fatalf("environment = %#v", payload.Environment)
+ }
+ if payload.Environment.ProviderStateJSON != nil {
+ t.Fatalf("environment provider state = %s, want omitted", string(payload.Environment.ProviderStateJSON))
+ }
}
func TestAgentPayloadFromDef(t *testing.T) {
diff --git a/internal/api/core/errors.go b/internal/api/core/errors.go
index c86c75626..713e03efc 100644
--- a/internal/api/core/errors.go
+++ b/internal/api/core/errors.go
@@ -13,6 +13,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/network"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
taskpkg "github.com/pedronauck/agh/internal/task"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
@@ -67,6 +68,34 @@ func StatusForMemoryError(err error) int {
}
}
+// StatusForResourceError maps desired-state resource failures to transport statuses.
+func StatusForResourceError(err error) int {
+ switch {
+ case err == nil:
+ return http.StatusOK
+ case errors.Is(err, resources.ErrPermissionDenied),
+ errors.Is(err, resources.ErrDirectMutationNotAllowed):
+ return http.StatusForbidden
+ case errors.Is(err, resources.ErrConflict),
+ errors.Is(err, resources.ErrSessionNotActive),
+ errors.Is(err, resources.ErrStaleSourceVersion):
+ return http.StatusConflict
+ case errors.Is(err, resources.ErrPayloadTooLarge):
+ return http.StatusRequestEntityTooLarge
+ case errors.Is(err, resources.ErrRateLimited):
+ return http.StatusTooManyRequests
+ case errors.Is(err, resources.ErrNotFound), errors.Is(err, os.ErrNotExist):
+ return http.StatusNotFound
+ case errors.Is(err, resources.ErrValidation),
+ errors.Is(err, resources.ErrInvalidScopeBinding),
+ errors.Is(err, resources.ErrCodecNotFound),
+ errors.Is(err, resources.ErrCodecTypeMismatch):
+ return http.StatusUnprocessableEntity
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
// NewTaskValidationError wraps a task validation failure with the shared sentinel.
func NewTaskValidationError(err error) error {
if err == nil {
diff --git a/internal/api/core/handlers.go b/internal/api/core/handlers.go
index 1b2e3d975..84f0c5f53 100644
--- a/internal/api/core/handlers.go
+++ b/internal/api/core/handlers.go
@@ -36,11 +36,13 @@ type BaseHandlerConfig struct {
Network NetworkService
NetworkStore NetworkStore
Observer Observer
+ Resources ResourceService
Automation AutomationManager
Tasks TaskService
Bridges BridgeService
Bundles BundleService
Workspaces WorkspaceService
+ AgentCatalog AgentCatalog
SkillsRegistry SkillsRegistry
TaskActorContextResolver TaskActorContextResolver
MemoryStore *memory.Store
@@ -66,11 +68,13 @@ type BaseHandlers struct {
Network NetworkService
NetworkStore NetworkStore
Observer Observer
+ Resources ResourceService
Automation AutomationManager
Tasks TaskService
Bridges BridgeService
Bundles BundleService
Workspaces WorkspaceService
+ AgentCatalog AgentCatalog
SkillsRegistry SkillsRegistry
TaskActorContextResolver TaskActorContextResolver
MemoryStore *memory.Store
@@ -136,11 +140,13 @@ func NewBaseHandlers(cfg *BaseHandlerConfig) *BaseHandlers {
Network: cfg.Network,
NetworkStore: cfg.NetworkStore,
Observer: cfg.Observer,
+ Resources: cfg.Resources,
Automation: cfg.Automation,
Tasks: cfg.Tasks,
Bridges: cfg.Bridges,
Bundles: cfg.Bundles,
Workspaces: cfg.Workspaces,
+ AgentCatalog: cfg.AgentCatalog,
SkillsRegistry: cfg.SkillsRegistry,
TaskActorContextResolver: cfg.TaskActorContextResolver,
MemoryStore: cfg.MemoryStore,
@@ -393,6 +399,27 @@ func (h *BaseHandlers) StreamSession(c *gin.Context) {
// ListAgents returns all readable agent definitions in home paths.
func (h *BaseHandlers) ListAgents(c *gin.Context) {
+ if h.AgentCatalog != nil {
+ agentDefs, err := h.AgentCatalog.ListAgents(c.Request.Context())
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ c.JSON(http.StatusOK, contract.AgentsResponse{Agents: []contract.AgentPayload{}})
+ return
+ }
+ h.respondError(c, http.StatusInternalServerError, err)
+ return
+ }
+ agents := make([]contract.AgentPayload, 0, len(agentDefs))
+ for _, agent := range agentDefs {
+ agents = append(agents, AgentPayloadFromDef(agent))
+ }
+ sort.Slice(agents, func(i, j int) bool {
+ return agents[i].Name < agents[j].Name
+ })
+ c.JSON(http.StatusOK, contract.AgentsResponse{Agents: agents})
+ return
+ }
+
entries, err := os.ReadDir(h.HomePaths.AgentsDir)
switch {
case err == nil:
@@ -436,6 +463,20 @@ func (h *BaseHandlers) ListAgents(c *gin.Context) {
// GetAgent returns one agent definition by name.
func (h *BaseHandlers) GetAgent(c *gin.Context) {
+ if h.AgentCatalog != nil {
+ agent, err := h.AgentCatalog.GetAgent(c.Request.Context(), c.Param("name"))
+ if err != nil {
+ status := http.StatusInternalServerError
+ if errors.Is(err, os.ErrNotExist) {
+ status = http.StatusNotFound
+ }
+ h.respondError(c, status, err)
+ return
+ }
+ c.JSON(http.StatusOK, contract.AgentResponse{Agent: AgentPayloadFromDef(agent)})
+ return
+ }
+
agent, err := h.AgentLoader(c.Param("name"), h.HomePaths)
if err != nil {
status := http.StatusInternalServerError
diff --git a/internal/api/core/handlers_test.go b/internal/api/core/handlers_test.go
index 0b52aea82..a14e7c128 100644
--- a/internal/api/core/handlers_test.go
+++ b/internal/api/core/handlers_test.go
@@ -2,14 +2,17 @@ package core_test
import (
"context"
+ "encoding/json"
"errors"
"net/http"
+ "os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/pedronauck/agh/internal/api/contract"
+ core "github.com/pedronauck/agh/internal/api/core"
"github.com/pedronauck/agh/internal/api/testutil"
aghconfig "github.com/pedronauck/agh/internal/config"
"github.com/pedronauck/agh/internal/network"
@@ -308,6 +311,97 @@ func TestBaseHandlersAgentEndpoints(t *testing.T) {
}
}
+func TestBaseHandlersAgentCatalogEndpoints(t *testing.T) {
+ t.Parallel()
+
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{},
+ testutil.StubObserver{},
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+ fixture.Handlers.AgentCatalog = stubAgentCatalog{
+ agents: []aghconfig.AgentDef{
+ {Name: "zeta", Prompt: "Zeta prompt"},
+ {Name: "alpha", Prompt: "Alpha prompt"},
+ },
+ get: map[string]aghconfig.AgentDef{
+ "alpha": {Name: "alpha", Prompt: "Alpha prompt"},
+ },
+ }
+
+ listResp := performRequest(t, fixture.Engine, http.MethodGet, "/agents", nil)
+ if listResp.Code != http.StatusOK {
+ t.Fatalf("list agent catalog status = %d, want %d", listResp.Code, http.StatusOK)
+ }
+ var listed contract.AgentsResponse
+ if err := json.Unmarshal(listResp.Body.Bytes(), &listed); err != nil {
+ t.Fatalf("json.Unmarshal(list agents) error = %v", err)
+ }
+ if len(listed.Agents) != 2 || listed.Agents[0].Name != "alpha" || listed.Agents[1].Name != "zeta" {
+ t.Fatalf("listed agents = %#v, want alpha then zeta", listed.Agents)
+ }
+
+ getResp := performRequest(t, fixture.Engine, http.MethodGet, "/agents/alpha", nil)
+ if getResp.Code != http.StatusOK {
+ t.Fatalf("get agent catalog status = %d, want %d", getResp.Code, http.StatusOK)
+ }
+
+ fixture.Handlers.AgentCatalog = stubAgentCatalog{getErr: os.ErrNotExist}
+ missingResp := performRequest(t, fixture.Engine, http.MethodGet, "/agents/missing", nil)
+ if missingResp.Code != http.StatusNotFound {
+ t.Fatalf("get missing catalog agent status = %d, want %d", missingResp.Code, http.StatusNotFound)
+ }
+
+ fixture.Handlers.AgentCatalog = stubAgentCatalog{listErr: os.ErrNotExist}
+ missingListResp := performRequest(t, fixture.Engine, http.MethodGet, "/agents", nil)
+ if missingListResp.Code != http.StatusOK {
+ t.Fatalf("list missing catalog status = %d, want %d", missingListResp.Code, http.StatusOK)
+ }
+ var missingList contract.AgentsResponse
+ if err := json.Unmarshal(missingListResp.Body.Bytes(), &missingList); err != nil {
+ t.Fatalf("json.Unmarshal(missing list agents) error = %v", err)
+ }
+ if len(missingList.Agents) != 0 {
+ t.Fatalf("missing catalog agents = %#v, want empty list", missingList.Agents)
+ }
+
+ fixture.Handlers.AgentCatalog = stubAgentCatalog{listErr: errors.New("catalog unavailable")}
+ errorResp := performRequest(t, fixture.Engine, http.MethodGet, "/agents", nil)
+ if errorResp.Code != http.StatusInternalServerError {
+ t.Fatalf("list catalog error status = %d, want %d", errorResp.Code, http.StatusInternalServerError)
+ }
+}
+
+type stubAgentCatalog struct {
+ agents []aghconfig.AgentDef
+ get map[string]aghconfig.AgentDef
+ listErr error
+ getErr error
+}
+
+var _ core.AgentCatalog = stubAgentCatalog{}
+
+func (s stubAgentCatalog) ListAgents(context.Context) ([]aghconfig.AgentDef, error) {
+ if s.listErr != nil {
+ return nil, s.listErr
+ }
+ return append([]aghconfig.AgentDef(nil), s.agents...), nil
+}
+
+func (s stubAgentCatalog) GetAgent(_ context.Context, name string) (aghconfig.AgentDef, error) {
+ if s.getErr != nil {
+ return aghconfig.AgentDef{}, s.getErr
+ }
+ agent, ok := s.get[name]
+ if !ok {
+ return aghconfig.AgentDef{}, os.ErrNotExist
+ }
+ return agent, nil
+}
+
func TestDaemonStatusIncludesNetworkDiagnosticsWithoutCredentials(t *testing.T) {
t.Parallel()
diff --git a/internal/api/core/interfaces.go b/internal/api/core/interfaces.go
index 4cbed3151..8b46e34b3 100644
--- a/internal/api/core/interfaces.go
+++ b/internal/api/core/interfaces.go
@@ -13,6 +13,7 @@ import (
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/network"
"github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
@@ -24,6 +25,12 @@ import (
// AgentLoader loads one parsed AGENT.md definition.
type AgentLoader func(name string, homePaths aghconfig.HomePaths) (aghconfig.AgentDef, error)
+// AgentCatalog exposes projected resource-backed agent definitions.
+type AgentCatalog interface {
+ ListAgents(ctx context.Context) ([]aghconfig.AgentDef, error)
+ GetAgent(ctx context.Context, name string) (aghconfig.AgentDef, error)
+}
+
// SessionManager is the runtime session surface exposed by API transports.
// List returns the current in-memory session snapshot without performing I/O.
// ListAll may perform I/O to return the authoritative session set, so it accepts a context.
@@ -100,6 +107,14 @@ type DreamTrigger interface {
Enabled() bool
}
+// ResourceService exposes the operator-facing desired-state CRUD surface to API transports.
+type ResourceService interface {
+ List(ctx context.Context, filter resources.ResourceFilter) ([]resources.RawRecord, error)
+ Get(ctx context.Context, kind resources.ResourceKind, id string) (resources.RawRecord, error)
+ Put(ctx context.Context, draft resources.RawDraft) (resources.RawRecord, error)
+ Delete(ctx context.Context, kind resources.ResourceKind, id string, expectedVersion int64) error
+}
+
// AutomationManager exposes automation state and control surfaces to the API layer.
type AutomationManager interface {
ListJobs(ctx context.Context, query automationpkg.JobListQuery) ([]automationpkg.Job, error)
diff --git a/internal/api/core/memory_workspace_test.go b/internal/api/core/memory_workspace_test.go
index 6bcca16a8..c9c7fc6ab 100644
--- a/internal/api/core/memory_workspace_test.go
+++ b/internal/api/core/memory_workspace_test.go
@@ -255,6 +255,7 @@ func TestWorkspaceHandlersDelegateToService(t *testing.T) {
AdditionalDirs: []string{addDir},
Name: "alpha",
DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 3, 12, 1, 0, 0, time.UTC),
}
@@ -275,7 +276,9 @@ func TestWorkspaceHandlersDelegateToService(t *testing.T) {
resolveCalled := false
workspaces := testutil.StubWorkspaceService{
RegisterFn: func(_ context.Context, opts workspacepkg.RegisterOptions) (workspacepkg.Workspace, error) {
- if opts.RootDir != rootDir || len(opts.AdditionalDirs) != 1 || opts.DefaultAgent != "coder" {
+ if opts.RootDir != rootDir || len(opts.AdditionalDirs) != 1 ||
+ opts.DefaultAgent != "coder" ||
+ opts.EnvironmentRef != "daytona-dev" {
t.Fatalf("Register opts = %#v", opts)
}
return workspace, nil
@@ -294,6 +297,9 @@ func TestWorkspaceHandlersDelegateToService(t *testing.T) {
if id != workspace.ID || opts.Name == nil || *opts.Name != "beta" {
t.Fatalf("Update call = %q %#v", id, opts)
}
+ if opts.EnvironmentRef != nil && *opts.EnvironmentRef != "local-dev" {
+ t.Fatalf("Update environment ref = %#v, want local-dev", opts.EnvironmentRef)
+ }
return nil
},
UnregisterFn: func(_ context.Context, id string) error {
@@ -334,10 +340,11 @@ func TestWorkspaceHandlersDelegateToService(t *testing.T) {
fixture, _, _, _, _, _, rootDir, addDir := setup(t)
createBody, err := json.Marshal(contract.CreateWorkspaceRequest{
- RootDir: rootDir,
- AddDirs: []string{addDir},
- Name: "alpha",
- DefaultAgent: "coder",
+ RootDir: rootDir,
+ AddDirs: []string{addDir},
+ Name: "alpha",
+ DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
})
if err != nil {
t.Fatalf("json.Marshal(create workspace request) error = %v", err)
@@ -476,7 +483,7 @@ func TestWorkspaceUpdateSupportsAddDirsAndDefaultAgent(t *testing.T) {
fixture.Engine,
http.MethodPatch,
"/workspaces/ws_alpha",
- []byte(`{"add_dirs":["`+addDir+`"],"default_agent":"coder"}`),
+ []byte(`{"add_dirs":["`+addDir+`"],"default_agent":"coder","environment_ref":"local-dev"}`),
)
if resp.Code != http.StatusOK {
t.Fatalf("update add_dirs/default_agent status = %d, want %d", resp.Code, http.StatusOK)
@@ -488,6 +495,9 @@ func TestWorkspaceUpdateSupportsAddDirsAndDefaultAgent(t *testing.T) {
if captured.DefaultAgent == nil || *captured.DefaultAgent != "coder" {
t.Fatalf("captured default agent = %#v", captured.DefaultAgent)
}
+ if captured.EnvironmentRef == nil || *captured.EnvironmentRef != "local-dev" {
+ t.Fatalf("captured environment ref = %#v", captured.EnvironmentRef)
+ }
})
}
diff --git a/internal/api/core/more_coverage_test.go b/internal/api/core/more_coverage_test.go
index af3f33a8c..250711388 100644
--- a/internal/api/core/more_coverage_test.go
+++ b/internal/api/core/more_coverage_test.go
@@ -14,8 +14,11 @@ import (
"github.com/pedronauck/agh/internal/api/contract"
"github.com/pedronauck/agh/internal/api/core"
"github.com/pedronauck/agh/internal/api/testutil"
+ automationpkg "github.com/pedronauck/agh/internal/automation"
aghconfig "github.com/pedronauck/agh/internal/config"
"github.com/pedronauck/agh/internal/memory"
+ "github.com/pedronauck/agh/internal/network"
+ "github.com/pedronauck/agh/internal/observe"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
@@ -248,6 +251,298 @@ func TestMemoryWrapperExports(t *testing.T) {
}
}
+func TestBaseHandlersHealthAndDaemonStatusErrorBranches(t *testing.T) {
+ t.Parallel()
+
+ t.Run("health observer failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{},
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{}, errors.New("boom")
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/observe/health", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "health status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("health memory failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{},
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ &stubDreamTrigger{EnabledFn: true, LastErr: errors.New("dream status failed")},
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/observe/health", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "health status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("health automation failure", func(t *testing.T) {
+ fixture := newHandlerFixtureWithAutomation(
+ t,
+ testutil.StubSessionManager{},
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubAutomationManager{
+ StatusFn: func(context.Context) (automationpkg.ManagerStatus, error) {
+ return automationpkg.ManagerStatus{}, errors.New("automation status failed")
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/observe/health", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "health status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("daemon status session list failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return nil, errors.New("list failed")
+ },
+ },
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/daemon/status", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "daemon status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("daemon status observer failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{},
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{}, errors.New("observer failed")
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/daemon/status", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "daemon status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("daemon status network enabled without service", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return []*session.Info{}, nil
+ },
+ },
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+ fixture.Handlers.Config.Network.Enabled = true
+ fixture.Handlers.Network = nil
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/daemon/status", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "daemon status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("daemon status network missing payload", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return []*session.Info{}, nil
+ },
+ },
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+ fixture.Handlers.Config.Network.Enabled = true
+ fixture.Handlers.Network = testutil.StubNetworkService{
+ StatusFn: func(context.Context) (*network.Status, error) {
+ return nil, nil
+ },
+ }
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/daemon/status", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "daemon status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("daemon status network failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return []*session.Info{}, nil
+ },
+ },
+ testutil.StubObserver{
+ HealthFn: func(context.Context) (observe.Health, error) {
+ return observe.Health{Status: "ok", Version: "dev"}, nil
+ },
+ },
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+ fixture.Handlers.Config.Network.Enabled = true
+ fixture.Handlers.Network = testutil.StubNetworkService{
+ StatusFn: func(context.Context) (*network.Status, error) {
+ return nil, errors.New("network failed")
+ },
+ }
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/daemon/status", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "daemon status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+}
+
+func TestBaseHandlersListSessionsErrorBranches(t *testing.T) {
+ t.Parallel()
+
+ t.Run("list all failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return nil, errors.New("list failed")
+ },
+ },
+ testutil.StubObserver{},
+ testutil.StubWorkspaceService{},
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/sessions", nil)
+ if resp.Code != http.StatusInternalServerError {
+ t.Fatalf(
+ "list sessions status = %d, want %d; body=%s",
+ resp.Code,
+ http.StatusInternalServerError,
+ resp.Body.String(),
+ )
+ }
+ })
+
+ t.Run("workspace lookup failure", func(t *testing.T) {
+ fixture := newHandlerFixture(
+ t,
+ testutil.StubSessionManager{
+ ListAllFn: func(context.Context) ([]*session.Info, error) {
+ return []*session.Info{{ID: "sess-1", WorkspaceID: "ws_alpha"}}, nil
+ },
+ },
+ testutil.StubObserver{},
+ testutil.StubWorkspaceService{
+ GetFn: func(context.Context, string) (workspacepkg.Workspace, error) {
+ return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound
+ },
+ },
+ nil,
+ nil,
+ )
+
+ resp := performRequest(t, fixture.Engine, http.MethodGet, "/sessions?workspace=alpha", nil)
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("list sessions status = %d, want %d; body=%s", resp.Code, http.StatusNotFound, resp.Body.String())
+ }
+ })
+}
+
func TestObserveStreamAndParseObserveQuery(t *testing.T) {
t.Parallel()
diff --git a/internal/api/core/resources.go b/internal/api/core/resources.go
new file mode 100644
index 000000000..2ac5b67a5
--- /dev/null
+++ b/internal/api/core/resources.go
@@ -0,0 +1,463 @@
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pedronauck/agh/internal/api/contract"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+var errResourceServiceUnavailable = errors.New("resource service is not configured")
+
+// ResourceServiceConfig configures the shared operator-facing resource service.
+type ResourceServiceConfig struct {
+ RawStore resources.RawStore
+ CodecRegistry *resources.CodecRegistry
+ Actor resources.MutationActor
+ Trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+}
+
+type operatorResourceService struct {
+ rawStore resources.RawStore
+ codecRegistry *resources.CodecRegistry
+ actor resources.MutationActor
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+}
+
+// NewOperatorResourceService constructs the shared desired-state CRUD service
+// used by HTTP and UDS handlers. Resource writes validate against registered
+// codecs when available and otherwise fall back to the raw kernel semantics so
+// the generic control plane remains usable before family codecs land.
+func NewOperatorResourceService(cfg *ResourceServiceConfig) (ResourceService, error) {
+ if cfg == nil {
+ return nil, errors.New("apicore: resource service config is required")
+ }
+ if cfg.RawStore == nil {
+ return nil, errors.New("apicore: resource raw store is required")
+ }
+
+ actor := cfg.Actor
+ if actor.Kind == "" {
+ actor = defaultResourceControlActor()
+ }
+
+ return &operatorResourceService{
+ rawStore: cfg.RawStore,
+ codecRegistry: cfg.CodecRegistry,
+ actor: actor,
+ trigger: cfg.Trigger,
+ }, nil
+}
+
+func defaultResourceControlActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "daemon-control",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "system",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *operatorResourceService) List(
+ ctx context.Context,
+ filter resources.ResourceFilter,
+) ([]resources.RawRecord, error) {
+ return s.rawStore.ListRaw(ctx, s.actor, filter)
+}
+
+func (s *operatorResourceService) Get(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+) (resources.RawRecord, error) {
+ return s.rawStore.GetRaw(ctx, s.actor, kind, id)
+}
+
+func (s *operatorResourceService) Put(
+ ctx context.Context,
+ draft resources.RawDraft,
+) (resources.RawRecord, error) {
+ specJSON := append([]byte(nil), draft.SpecJSON...)
+ if s.codecRegistry != nil {
+ canonical, _, err := resources.ValidateAndCanonicalizeIfRegistered(
+ ctx,
+ s.codecRegistry,
+ draft.Kind,
+ draft.Scope,
+ specJSON,
+ )
+ if err != nil {
+ return resources.RawRecord{}, err
+ }
+ specJSON = canonical
+ }
+
+ next := draft
+ next.SpecJSON = specJSON
+ record, err := s.rawStore.PutRaw(ctx, s.actor, next)
+ if err != nil {
+ return resources.RawRecord{}, err
+ }
+ if s.trigger != nil {
+ if err := s.trigger(ctx, next.Kind, resources.ReconcileReasonWrite); err != nil {
+ return resources.RawRecord{}, err
+ }
+ }
+ return record, nil
+}
+
+func (s *operatorResourceService) Delete(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+ expectedVersion int64,
+) error {
+ if err := s.rawStore.DeleteRaw(ctx, s.actor, kind, id, expectedVersion); err != nil {
+ return err
+ }
+ if s.trigger != nil {
+ if err := s.trigger(ctx, kind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ListResources lists desired-state resources under the shared operator service.
+func (h *BaseHandlers) ListResources(c *gin.Context) {
+ service := h.Resources
+ if service == nil {
+ h.respondError(c, http.StatusServiceUnavailable, errResourceServiceUnavailable)
+ return
+ }
+
+ filter, err := ParseResourceFilter(c)
+ if err != nil {
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ records, err := service.List(c.Request.Context(), filter)
+ if err != nil {
+ h.respondError(c, StatusForResourceError(err), err)
+ return
+ }
+
+ c.JSON(http.StatusOK, contract.ResourcesResponse{Records: ResourceRecordPayloadsFromRaw(records)})
+}
+
+// GetResource returns one desired-state resource by kind and id.
+func (h *BaseHandlers) GetResource(c *gin.Context) {
+ service := h.Resources
+ if service == nil {
+ h.respondError(c, http.StatusServiceUnavailable, errResourceServiceUnavailable)
+ return
+ }
+
+ kind, id, err := parseResourcePath(c)
+ if err != nil {
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ record, err := service.Get(c.Request.Context(), kind, id)
+ if err != nil {
+ h.respondError(c, StatusForResourceError(err), err)
+ return
+ }
+
+ c.JSON(http.StatusOK, contract.ResourceResponse{Record: ResourceRecordPayloadFromRaw(record)})
+}
+
+// PutResource creates or updates one desired-state resource.
+func (h *BaseHandlers) PutResource(c *gin.Context) {
+ service := h.Resources
+ if service == nil {
+ h.respondError(c, http.StatusServiceUnavailable, errResourceServiceUnavailable)
+ return
+ }
+
+ kind, id, err := parseResourcePath(c)
+ if err != nil {
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ var req contract.PutResourceRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.respondError(
+ c,
+ http.StatusBadRequest,
+ fmt.Errorf("%s: decode resource put request: %w", h.transportName(), err),
+ )
+ return
+ }
+
+ draft, err := parseResourcePutDraft(kind, id, req)
+ if err != nil {
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ record, err := service.Put(c.Request.Context(), draft)
+ if err != nil {
+ h.respondError(c, StatusForResourceError(err), err)
+ return
+ }
+
+ status := http.StatusOK
+ if draft.ExpectedVersion == 0 {
+ status = http.StatusCreated
+ }
+ c.JSON(status, contract.ResourceResponse{Record: ResourceRecordPayloadFromRaw(record)})
+}
+
+// DeleteResource deletes one desired-state resource by optimistic version.
+func (h *BaseHandlers) DeleteResource(c *gin.Context) {
+ service := h.Resources
+ if service == nil {
+ h.respondError(c, http.StatusServiceUnavailable, errResourceServiceUnavailable)
+ return
+ }
+
+ kind, id, err := parseResourcePath(c)
+ if err != nil {
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ var req contract.DeleteResourceRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.respondError(
+ c,
+ http.StatusBadRequest,
+ fmt.Errorf("%s: decode resource delete request: %w", h.transportName(), err),
+ )
+ return
+ }
+ if req.ExpectedVersion <= 0 {
+ err := fmt.Errorf("%w: expected_version must be positive", resources.ErrValidation)
+ h.respondError(c, statusForResourceRequestError(err), err)
+ return
+ }
+
+ if err := service.Delete(c.Request.Context(), kind, id, req.ExpectedVersion); err != nil {
+ h.respondError(c, StatusForResourceError(err), err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// ParseResourceFilter parses the shared `/api/resources` list filters.
+func ParseResourceFilter(c *gin.Context) (resources.ResourceFilter, error) {
+ if c == nil {
+ return resources.ResourceFilter{}, fmt.Errorf(
+ "%w: resource filter context is required",
+ resources.ErrValidation,
+ )
+ }
+
+ limit, err := ParseOptionalInt(c.Query("limit"))
+ if err != nil {
+ return resources.ResourceFilter{}, err
+ }
+
+ pathKind := strings.TrimSpace(c.Param("kind"))
+ queryKind := strings.TrimSpace(c.Query("kind"))
+ switch {
+ case pathKind != "" && queryKind != "" && pathKind != queryKind:
+ return resources.ResourceFilter{}, fmt.Errorf(
+ "%w: resource kind path %q does not match query %q",
+ resources.ErrValidation,
+ pathKind,
+ queryKind,
+ )
+ case pathKind != "":
+ queryKind = pathKind
+ }
+
+ filter := resources.ResourceFilter{
+ Kind: resources.ResourceKind(queryKind),
+ Limit: limit,
+ }
+ if filter.Kind != "" {
+ if err := filter.Kind.Validate("filter.kind"); err != nil {
+ return resources.ResourceFilter{}, err
+ }
+ }
+
+ scope, hasScope, err := parseResourceScopeQuery(c.Query("scope_kind"), c.Query("scope_id"), "filter.scope")
+ if err != nil {
+ return resources.ResourceFilter{}, err
+ }
+ if hasScope {
+ filter.Scope = &scope
+ }
+
+ owner, hasOwner, err := parseResourceOwnerQuery(c.Query("owner_kind"), c.Query("owner_id"), "filter.owner")
+ if err != nil {
+ return resources.ResourceFilter{}, err
+ }
+ if hasOwner {
+ filter.Owner = &owner
+ }
+
+ source, hasSource, err := parseResourceSourceQuery(c.Query("source_kind"), c.Query("source_id"), "filter.source")
+ if err != nil {
+ return resources.ResourceFilter{}, err
+ }
+ if hasSource {
+ filter.Source = &source
+ }
+
+ return filter, nil
+}
+
+// ResourceRecordPayloadsFromRaw converts raw resource records into shared transport DTOs.
+func ResourceRecordPayloadsFromRaw(records []resources.RawRecord) []contract.ResourceRecordPayload {
+ if len(records) == 0 {
+ return []contract.ResourceRecordPayload{}
+ }
+
+ payloads := make([]contract.ResourceRecordPayload, 0, len(records))
+ for _, record := range records {
+ payloads = append(payloads, ResourceRecordPayloadFromRaw(record))
+ }
+ return payloads
+}
+
+// ResourceRecordPayloadFromRaw converts one raw resource record into a shared transport DTO.
+func ResourceRecordPayloadFromRaw(record resources.RawRecord) contract.ResourceRecordPayload {
+ return contract.ResourceRecordPayload{
+ Kind: record.Kind,
+ ID: record.ID,
+ Version: record.Version,
+ Scope: record.Scope,
+ Owner: record.Owner,
+ Source: record.Source,
+ Spec: append(json.RawMessage(nil), record.SpecJSON...),
+ CreatedAt: record.CreatedAt,
+ UpdatedAt: record.UpdatedAt,
+ }
+}
+
+func statusForResourceRequestError(err error) int {
+ status := StatusForResourceError(err)
+ if status == http.StatusInternalServerError {
+ return http.StatusBadRequest
+ }
+ return status
+}
+
+func parseResourcePath(c *gin.Context) (resources.ResourceKind, string, error) {
+ kind := resources.ResourceKind(strings.TrimSpace(c.Param("kind")))
+ id := strings.TrimSpace(c.Param("id"))
+
+ if err := kind.Validate("kind"); err != nil {
+ return "", "", err
+ }
+ if id == "" {
+ return "", "", fmt.Errorf("%w: id is required", resources.ErrValidation)
+ }
+
+ return kind, id, nil
+}
+
+func parseResourcePutDraft(
+ kind resources.ResourceKind,
+ id string,
+ req contract.PutResourceRequest,
+) (resources.RawDraft, error) {
+ scope := req.Scope.Normalize()
+ if err := scope.Validate("scope"); err != nil {
+ return resources.RawDraft{}, err
+ }
+ if req.ExpectedVersion < 0 {
+ return resources.RawDraft{}, fmt.Errorf(
+ "%w: expected_version cannot be negative: %d",
+ resources.ErrValidation,
+ req.ExpectedVersion,
+ )
+ }
+
+ return resources.RawDraft{
+ Kind: kind,
+ ID: id,
+ Scope: scope,
+ ExpectedVersion: req.ExpectedVersion,
+ SpecJSON: append([]byte(nil), req.Spec...),
+ }, nil
+}
+
+func parseResourceScopeQuery(
+ rawKind string,
+ rawID string,
+ path string,
+) (resources.ResourceScope, bool, error) {
+ scopeKind := strings.TrimSpace(rawKind)
+ scopeID := strings.TrimSpace(rawID)
+ if scopeKind == "" && scopeID == "" {
+ return resources.ResourceScope{}, false, nil
+ }
+
+ scope := resources.ResourceScope{
+ Kind: resources.ResourceScopeKind(scopeKind),
+ ID: scopeID,
+ }.Normalize()
+ if err := scope.Validate(path); err != nil {
+ return resources.ResourceScope{}, false, err
+ }
+ return scope, true, nil
+}
+
+func parseResourceOwnerQuery(
+ rawKind string,
+ rawID string,
+ path string,
+) (resources.ResourceOwner, bool, error) {
+ ownerKind := strings.TrimSpace(rawKind)
+ ownerID := strings.TrimSpace(rawID)
+ if ownerKind == "" && ownerID == "" {
+ return resources.ResourceOwner{}, false, nil
+ }
+
+ owner := resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind(ownerKind),
+ ID: ownerID,
+ }.Normalize()
+ if err := owner.Validate(path); err != nil {
+ return resources.ResourceOwner{}, false, err
+ }
+ return owner, true, nil
+}
+
+func parseResourceSourceQuery(
+ rawKind string,
+ rawID string,
+ path string,
+) (resources.ResourceSource, bool, error) {
+ sourceKind := strings.TrimSpace(rawKind)
+ sourceID := strings.TrimSpace(rawID)
+ if sourceKind == "" && sourceID == "" {
+ return resources.ResourceSource{}, false, nil
+ }
+
+ source := resources.ResourceSource{
+ Kind: resources.ResourceSourceKind(sourceKind),
+ ID: sourceID,
+ }.Normalize()
+ if err := source.Validate(path); err != nil {
+ return resources.ResourceSource{}, false, err
+ }
+ return source, true, nil
+}
diff --git a/internal/api/core/resources_test.go b/internal/api/core/resources_test.go
new file mode 100644
index 000000000..6aca529c7
--- /dev/null
+++ b/internal/api/core/resources_test.go
@@ -0,0 +1,1010 @@
+package core
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pedronauck/agh/internal/api/contract"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+type stubRawStore struct {
+ PutRawFn func(context.Context, resources.MutationActor, resources.RawDraft) (resources.RawRecord, error)
+ DeleteRawFn func(context.Context, resources.MutationActor, resources.ResourceKind, string, int64) error
+ ApplySourceSnapshotFn func(context.Context, resources.MutationActor, resources.SourceSnapshot) error
+ GetRawFn func(context.Context, resources.MutationActor, resources.ResourceKind, string) (resources.RawRecord, error)
+ ListRawFn func(context.Context, resources.MutationActor, resources.ResourceFilter) ([]resources.RawRecord, error)
+}
+
+func (s stubRawStore) PutRaw(
+ ctx context.Context,
+ actor resources.MutationActor,
+ draft resources.RawDraft,
+) (resources.RawRecord, error) {
+ if s.PutRawFn != nil {
+ return s.PutRawFn(ctx, actor, draft)
+ }
+ return resources.RawRecord{}, nil
+}
+
+func (s stubRawStore) DeleteRaw(
+ ctx context.Context,
+ actor resources.MutationActor,
+ kind resources.ResourceKind,
+ id string,
+ expectedVersion int64,
+) error {
+ if s.DeleteRawFn != nil {
+ return s.DeleteRawFn(ctx, actor, kind, id, expectedVersion)
+ }
+ return nil
+}
+
+func (s stubRawStore) ApplySourceSnapshotRaw(
+ ctx context.Context,
+ actor resources.MutationActor,
+ snapshot resources.SourceSnapshot,
+) error {
+ if s.ApplySourceSnapshotFn != nil {
+ return s.ApplySourceSnapshotFn(ctx, actor, snapshot)
+ }
+ return nil
+}
+
+func (s stubRawStore) GetRaw(
+ ctx context.Context,
+ actor resources.MutationActor,
+ kind resources.ResourceKind,
+ id string,
+) (resources.RawRecord, error) {
+ if s.GetRawFn != nil {
+ return s.GetRawFn(ctx, actor, kind, id)
+ }
+ return resources.RawRecord{}, nil
+}
+
+func (s stubRawStore) ListRaw(
+ ctx context.Context,
+ actor resources.MutationActor,
+ filter resources.ResourceFilter,
+) ([]resources.RawRecord, error) {
+ if s.ListRawFn != nil {
+ return s.ListRawFn(ctx, actor, filter)
+ }
+ return nil, nil
+}
+
+type stubResourceService struct {
+ ListFn func(context.Context, resources.ResourceFilter) ([]resources.RawRecord, error)
+ GetFn func(context.Context, resources.ResourceKind, string) (resources.RawRecord, error)
+ PutFn func(context.Context, resources.RawDraft) (resources.RawRecord, error)
+ DeleteFn func(context.Context, resources.ResourceKind, string, int64) error
+}
+
+func (s stubResourceService) List(
+ ctx context.Context,
+ filter resources.ResourceFilter,
+) ([]resources.RawRecord, error) {
+ if s.ListFn != nil {
+ return s.ListFn(ctx, filter)
+ }
+ return nil, nil
+}
+
+func (s stubResourceService) Get(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+) (resources.RawRecord, error) {
+ if s.GetFn != nil {
+ return s.GetFn(ctx, kind, id)
+ }
+ return resources.RawRecord{}, nil
+}
+
+func (s stubResourceService) Put(
+ ctx context.Context,
+ draft resources.RawDraft,
+) (resources.RawRecord, error) {
+ if s.PutFn != nil {
+ return s.PutFn(ctx, draft)
+ }
+ return resources.RawRecord{}, nil
+}
+
+func (s stubResourceService) Delete(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+ expectedVersion int64,
+) error {
+ if s.DeleteFn != nil {
+ return s.DeleteFn(ctx, kind, id, expectedVersion)
+ }
+ return nil
+}
+
+func TestStatusForResourceError(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ err error
+ want int
+ }{
+ {name: "nil", err: nil, want: http.StatusOK},
+ {name: "validation", err: resources.ErrValidation, want: http.StatusUnprocessableEntity},
+ {name: "invalid scope", err: resources.ErrInvalidScopeBinding, want: http.StatusUnprocessableEntity},
+ {name: "codec mismatch", err: resources.ErrCodecTypeMismatch, want: http.StatusUnprocessableEntity},
+ {name: "permission denied", err: resources.ErrPermissionDenied, want: http.StatusForbidden},
+ {name: "direct mutation denied", err: resources.ErrDirectMutationNotAllowed, want: http.StatusForbidden},
+ {name: "conflict", err: resources.ErrConflict, want: http.StatusConflict},
+ {name: "stale source version", err: resources.ErrStaleSourceVersion, want: http.StatusConflict},
+ {name: "payload too large", err: resources.ErrPayloadTooLarge, want: http.StatusRequestEntityTooLarge},
+ {name: "rate limited", err: resources.ErrRateLimited, want: http.StatusTooManyRequests},
+ {name: "not found", err: resources.ErrNotFound, want: http.StatusNotFound},
+ {
+ name: "wrapped validation",
+ err: errors.Join(errors.New("boom"), resources.ErrValidation),
+ want: http.StatusUnprocessableEntity,
+ },
+ {name: "default", err: errors.New("boom"), want: http.StatusInternalServerError},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := StatusForResourceError(tt.err); got != tt.want {
+ t.Fatalf("StatusForResourceError(%v) = %d, want %d", tt.err, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseResourceFilterPreservesListSemantics(t *testing.T) {
+ t.Parallel()
+
+ ctx := newResourceTestContext(
+ t,
+ http.MethodGet,
+ "/api/resources/bundle.activation?scope_kind=workspace&scope_id=ws-alpha&owner_kind=daemon&owner_id=daemon-control&source_kind=daemon&source_id=system&limit=7",
+ )
+ ctx.Params = gin.Params{{Key: "kind", Value: "bundle.activation"}}
+
+ filter, err := ParseResourceFilter(ctx)
+ if err != nil {
+ t.Fatalf("ParseResourceFilter() error = %v", err)
+ }
+ if filter.Kind != resources.ResourceKind("bundle.activation") {
+ t.Fatalf("filter.Kind = %q, want %q", filter.Kind, resources.ResourceKind("bundle.activation"))
+ }
+ if filter.Limit != 7 {
+ t.Fatalf("filter.Limit = %d, want 7", filter.Limit)
+ }
+ if filter.Scope == nil || filter.Scope.Kind != resources.ResourceScopeKindWorkspace ||
+ filter.Scope.ID != "ws-alpha" {
+ t.Fatalf("filter.Scope = %#v, want workspace ws-alpha", filter.Scope)
+ }
+ if filter.Owner == nil || filter.Owner.Kind != resources.ResourceOwnerKind("daemon") ||
+ filter.Owner.ID != "daemon-control" {
+ t.Fatalf("filter.Owner = %#v, want daemon/daemon-control", filter.Owner)
+ }
+ if filter.Source == nil || filter.Source.Kind != resources.ResourceSourceKind("daemon") ||
+ filter.Source.ID != "system" {
+ t.Fatalf("filter.Source = %#v, want daemon/system", filter.Source)
+ }
+}
+
+func TestParseResourceFilterRejectsMismatchedPathAndQueryKinds(t *testing.T) {
+ t.Parallel()
+
+ ctx := newResourceTestContext(t, http.MethodGet, "/api/resources/bundle.activation?kind=bridge.instance")
+ ctx.Params = gin.Params{{Key: "kind", Value: "bundle.activation"}}
+
+ _, err := ParseResourceFilter(ctx)
+ if err == nil {
+ t.Fatal("ParseResourceFilter() error = nil, want non-nil")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("ParseResourceFilter() error = %v, want ErrValidation", err)
+ }
+ if got := statusForResourceRequestError(err); got != http.StatusUnprocessableEntity {
+ t.Fatalf("statusForResourceRequestError() = %d, want %d", got, http.StatusUnprocessableEntity)
+ }
+}
+
+func TestParseResourcePutDraftPreservesExpectedVersionAndScope(t *testing.T) {
+ t.Parallel()
+
+ wantSpec := []byte(`{"enabled":true}`)
+ spec := append([]byte(nil), wantSpec...)
+
+ draft, err := parseResourcePutDraft(
+ resources.ResourceKind("bundle.activation"),
+ "bundle-1",
+ contract.PutResourceRequest{
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: " ws-alpha "},
+ ExpectedVersion: 7,
+ Spec: spec,
+ },
+ )
+ if err != nil {
+ t.Fatalf("parseResourcePutDraft() error = %v", err)
+ }
+ if draft.Kind != resources.ResourceKind("bundle.activation") || draft.ID != "bundle-1" {
+ t.Fatalf("draft identity = %#v", draft)
+ }
+ if draft.Scope.Kind != resources.ResourceScopeKindWorkspace || draft.Scope.ID != "ws-alpha" {
+ t.Fatalf("draft.Scope = %#v, want workspace ws-alpha", draft.Scope)
+ }
+ if draft.ExpectedVersion != 7 {
+ t.Fatalf("draft.ExpectedVersion = %d, want 7", draft.ExpectedVersion)
+ }
+ spec[2] = 'X'
+ if !bytes.Equal(draft.SpecJSON, wantSpec) {
+ t.Fatalf("draft.SpecJSON = %s, want %s", string(draft.SpecJSON), string(wantSpec))
+ }
+}
+
+func TestParseResourcePutDraftRejectsNegativeExpectedVersion(t *testing.T) {
+ t.Parallel()
+
+ _, err := parseResourcePutDraft(
+ resources.ResourceKind("bundle.activation"),
+ "bundle-1",
+ contract.PutResourceRequest{
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ ExpectedVersion: -1,
+ Spec: []byte(`{"enabled":true}`),
+ },
+ )
+ if err == nil {
+ t.Fatal("parseResourcePutDraft() error = nil, want non-nil")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("parseResourcePutDraft() error = %v, want ErrValidation", err)
+ }
+}
+
+func TestResourceRecordPayloadFromRawCopiesSpec(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ record := resources.RawRecord{
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "bundle-1",
+ Version: 3,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{Kind: resources.ResourceOwnerKind("daemon"), ID: "daemon-control"},
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: []byte(`{"enabled":true}`),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ payload := ResourceRecordPayloadFromRaw(record)
+ record.SpecJSON[2] = 'X'
+ if !bytes.Equal(payload.Spec, []byte(`{"enabled":true}`)) {
+ t.Fatalf("payload.Spec = %s, want original JSON", string(payload.Spec))
+ }
+
+ payload.Spec[3] = 'Y'
+ if bytes.Equal(record.SpecJSON, payload.Spec) {
+ t.Fatal("payload.Spec shares backing storage with record.SpecJSON")
+ }
+}
+
+func TestNewOperatorResourceServiceRequiresRawStore(t *testing.T) {
+ t.Parallel()
+
+ if _, err := NewOperatorResourceService(nil); err == nil {
+ t.Fatal("NewOperatorResourceService(nil) error = nil, want non-nil")
+ }
+ if _, err := NewOperatorResourceService(&ResourceServiceConfig{}); err == nil {
+ t.Fatal("NewOperatorResourceService(missing store) error = nil, want non-nil")
+ }
+}
+
+func TestOperatorResourceServiceUsesDefaultControlActorAndCodecValidation(t *testing.T) {
+ t.Parallel()
+
+ type spec struct {
+ Name string `json:"name"`
+ }
+
+ registry := resources.NewCodecRegistry()
+ codec, err := resources.NewJSONCodec[spec](
+ resources.ResourceKind("bundle.activation"),
+ 1024,
+ func(_ context.Context, scope resources.ResourceScope, value spec) (spec, error) {
+ if scope.Kind != resources.ResourceScopeKindGlobal {
+ t.Fatalf("validator scope = %#v, want global", scope)
+ }
+ value.Name = strings.TrimSpace(value.Name)
+ return value, nil
+ },
+ )
+ if err != nil {
+ t.Fatalf("resources.NewJSONCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(registry, codec); err != nil {
+ t.Fatalf("resources.RegisterCodec() error = %v", err)
+ }
+
+ var listActor resources.MutationActor
+ var getActor resources.MutationActor
+ var putActor resources.MutationActor
+ var deleteActor resources.MutationActor
+ var gotFilter resources.ResourceFilter
+ var gotDraft resources.RawDraft
+
+ store := stubRawStore{
+ ListRawFn: func(_ context.Context, actor resources.MutationActor, filter resources.ResourceFilter) ([]resources.RawRecord, error) {
+ listActor = actor
+ gotFilter = filter
+ return []resources.RawRecord{{
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "demo",
+ Version: 2,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{Kind: resources.ResourceOwnerKind("daemon"), ID: "daemon-control"},
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: []byte(`{"name":"demo"}`),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }}, nil
+ },
+ GetRawFn: func(_ context.Context, actor resources.MutationActor, kind resources.ResourceKind, id string) (resources.RawRecord, error) {
+ getActor = actor
+ return resources.RawRecord{
+ Kind: kind,
+ ID: id,
+ Version: 2,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{Kind: resources.ResourceOwnerKind("daemon"), ID: "daemon-control"},
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: []byte(`{"name":"demo"}`),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }, nil
+ },
+ PutRawFn: func(_ context.Context, actor resources.MutationActor, draft resources.RawDraft) (resources.RawRecord, error) {
+ putActor = actor
+ gotDraft = draft
+ return resources.RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: 1,
+ Scope: draft.Scope,
+ Owner: resources.ResourceOwner{Kind: resources.ResourceOwnerKind("daemon"), ID: "daemon-control"},
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }, nil
+ },
+ DeleteRawFn: func(_ context.Context, actor resources.MutationActor, kind resources.ResourceKind, id string, expectedVersion int64) error {
+ deleteActor = actor
+ if kind != resources.ResourceKind("bundle.activation") || id != "demo" || expectedVersion != 2 {
+ t.Fatalf("DeleteRaw() args = kind:%q id:%q expected_version:%d", kind, id, expectedVersion)
+ }
+ return nil
+ },
+ }
+
+ service, err := NewOperatorResourceService(&ResourceServiceConfig{
+ RawStore: store,
+ CodecRegistry: registry,
+ })
+ if err != nil {
+ t.Fatalf("NewOperatorResourceService() error = %v", err)
+ }
+
+ records, err := service.List(
+ context.Background(),
+ resources.ResourceFilter{Kind: resources.ResourceKind("bundle.activation"), Limit: 5},
+ )
+ if err != nil {
+ t.Fatalf("service.List() error = %v", err)
+ }
+ if len(records) != 1 || gotFilter.Kind != resources.ResourceKind("bundle.activation") || gotFilter.Limit != 5 {
+ t.Fatalf("service.List() records=%#v filter=%#v", records, gotFilter)
+ }
+
+ if _, err := service.Get(context.Background(), resources.ResourceKind("bundle.activation"), "demo"); err != nil {
+ t.Fatalf("service.Get() error = %v", err)
+ }
+
+ if _, err := service.Put(
+ context.Background(),
+ resources.RawDraft{
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "demo",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"name":" demo "}`),
+ },
+ ); err != nil {
+ t.Fatalf("service.Put() error = %v", err)
+ }
+ if string(gotDraft.SpecJSON) != `{"name":"demo"}` {
+ t.Fatalf("service.Put() canonical spec = %s, want %s", string(gotDraft.SpecJSON), `{"name":"demo"}`)
+ }
+
+ if err := service.Delete(context.Background(), resources.ResourceKind("bundle.activation"), "demo", 2); err != nil {
+ t.Fatalf("service.Delete() error = %v", err)
+ }
+
+ for name, actor := range map[string]resources.MutationActor{
+ "list": listActor,
+ "get": getActor,
+ "put": putActor,
+ "delete": deleteActor,
+ } {
+ if actor.Kind != resources.MutationActorKindDaemon || actor.ID != "daemon-control" {
+ t.Fatalf("%s actor = %#v, want daemon-control", name, actor)
+ }
+ if actor.Source.Kind != resources.ResourceSourceKind("daemon") || actor.Source.ID != "system" {
+ t.Fatalf("%s actor source = %#v, want daemon/system", name, actor.Source)
+ }
+ if actor.MaxScope.Kind != resources.ResourceScopeKindGlobal {
+ t.Fatalf("%s actor max_scope = %#v, want global", name, actor.MaxScope)
+ }
+ }
+}
+
+func TestOperatorResourceServicePutReturnsCodecValidationError(t *testing.T) {
+ t.Parallel()
+
+ type spec struct {
+ Name string `json:"name"`
+ }
+
+ registry := resources.NewCodecRegistry()
+ codec, err := resources.NewJSONCodec[spec](
+ resources.ResourceKind("bundle.activation"),
+ 1024,
+ func(context.Context, resources.ResourceScope, spec) (spec, error) {
+ return spec{}, fmt.Errorf("%w: name is required", resources.ErrValidation)
+ },
+ )
+ if err != nil {
+ t.Fatalf("resources.NewJSONCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(registry, codec); err != nil {
+ t.Fatalf("resources.RegisterCodec() error = %v", err)
+ }
+
+ called := false
+ service, err := NewOperatorResourceService(&ResourceServiceConfig{
+ RawStore: stubRawStore{
+ PutRawFn: func(context.Context, resources.MutationActor, resources.RawDraft) (resources.RawRecord, error) {
+ called = true
+ return resources.RawRecord{}, nil
+ },
+ },
+ CodecRegistry: registry,
+ })
+ if err != nil {
+ t.Fatalf("NewOperatorResourceService() error = %v", err)
+ }
+
+ _, err = service.Put(
+ context.Background(),
+ resources.RawDraft{
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "demo",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"name":"demo"}`),
+ },
+ )
+ if err == nil {
+ t.Fatal("service.Put() error = nil, want non-nil")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("service.Put() error = %v, want ErrValidation", err)
+ }
+ if called {
+ t.Fatal("raw store PutRaw() was called after codec validation failed")
+ }
+}
+
+func TestBaseHandlersResourceEndpointsUseSharedSemantics(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+
+ t.Run("list", func(t *testing.T) {
+ var gotFilter resources.ResourceFilter
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: stubResourceService{
+ ListFn: func(_ context.Context, filter resources.ResourceFilter) ([]resources.RawRecord, error) {
+ gotFilter = filter
+ return []resources.RawRecord{
+ {
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "demo",
+ Version: 1,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "system",
+ },
+ SpecJSON: []byte(`{"enabled":true}`),
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }, nil
+ },
+ },
+ })
+ ctx, recorder := newResourceRequestContext(
+ t,
+ http.MethodGet,
+ "/api/resources/bundle.activation?scope_kind=global&limit=2",
+ nil,
+ gin.Params{{Key: "kind", Value: "bundle.activation"}},
+ )
+
+ handlers.ListResources(ctx)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
+ }
+ if gotFilter.Kind != resources.ResourceKind("bundle.activation") || gotFilter.Limit != 2 {
+ t.Fatalf("ListResources() filter = %#v", gotFilter)
+ }
+ })
+
+ t.Run("get", func(t *testing.T) {
+ var gotKind resources.ResourceKind
+ var gotID string
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: stubResourceService{
+ GetFn: func(_ context.Context, kind resources.ResourceKind, id string) (resources.RawRecord, error) {
+ gotKind = kind
+ gotID = id
+ return resources.RawRecord{
+ Kind: kind,
+ ID: id,
+ Version: 1,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: []byte(`{"enabled":true}`),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
+ },
+ },
+ })
+ ctx, recorder := newResourceRequestContext(
+ t,
+ http.MethodGet,
+ "/api/resources/bundle.activation/demo",
+ nil,
+ gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ )
+
+ handlers.GetResource(ctx)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
+ }
+ if gotKind != resources.ResourceKind("bundle.activation") || gotID != "demo" {
+ t.Fatalf("GetResource() args = kind:%q id:%q", gotKind, gotID)
+ }
+ })
+
+ t.Run("put", func(t *testing.T) {
+ var gotDraft resources.RawDraft
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: stubResourceService{
+ PutFn: func(_ context.Context, draft resources.RawDraft) (resources.RawRecord, error) {
+ gotDraft = draft
+ return resources.RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: 1,
+ Scope: draft.Scope,
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
+ },
+ },
+ })
+ ctx, recorder := newResourceRequestContext(
+ t,
+ http.MethodPut,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ )
+
+ handlers.PutResource(ctx)
+
+ if recorder.Code != http.StatusCreated {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
+ }
+ if gotDraft.Kind != resources.ResourceKind("bundle.activation") || gotDraft.ID != "demo" {
+ t.Fatalf("PutResource() draft = %#v", gotDraft)
+ }
+ })
+
+ t.Run("delete", func(t *testing.T) {
+ var gotKind resources.ResourceKind
+ var gotID string
+ var gotVersion int64
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: stubResourceService{
+ DeleteFn: func(_ context.Context, kind resources.ResourceKind, id string, expectedVersion int64) error {
+ gotKind = kind
+ gotID = id
+ gotVersion = expectedVersion
+ return nil
+ },
+ },
+ })
+ ctx, recorder := newResourceRequestContext(
+ t,
+ http.MethodDelete,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"expected_version":3}`),
+ gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ )
+
+ handlers.DeleteResource(ctx)
+
+ if got := ctx.Writer.Status(); got != http.StatusNoContent {
+ t.Fatalf(
+ "status = %d, want %d; recorder=%d body=%s",
+ got,
+ http.StatusNoContent,
+ recorder.Code,
+ recorder.Body.String(),
+ )
+ }
+ if gotKind != resources.ResourceKind("bundle.activation") || gotID != "demo" || gotVersion != 3 {
+ t.Fatalf("DeleteResource() args = kind:%q id:%q expected_version:%d", gotKind, gotID, gotVersion)
+ }
+ })
+}
+
+func TestBaseHandlersResourceEndpointsHandleUnavailableServicesAndBadRequests(t *testing.T) {
+ t.Parallel()
+
+ t.Run("service unavailable", func(t *testing.T) {
+ handlers := NewBaseHandlers(&BaseHandlerConfig{TransportName: "core-test"})
+
+ tests := []struct {
+ name string
+ method string
+ target string
+ body []byte
+ params gin.Params
+ call func(*BaseHandlers, *gin.Context)
+ }{
+ {
+ name: "list",
+ method: http.MethodGet,
+ target: "/api/resources",
+ call: (*BaseHandlers).ListResources,
+ },
+ {
+ name: "get",
+ method: http.MethodGet,
+ target: "/api/resources/bundle.activation/demo",
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).GetResource,
+ },
+ {
+ name: "put",
+ method: http.MethodPut,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).PutResource,
+ },
+ {
+ name: "delete",
+ method: http.MethodDelete,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"expected_version":1}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).DeleteResource,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, recorder := newResourceRequestContext(t, tt.method, tt.target, tt.body, tt.params)
+ tt.call(handlers, ctx)
+ if recorder.Code != http.StatusServiceUnavailable {
+ t.Fatalf(
+ "status = %d, want %d; body=%s",
+ recorder.Code,
+ http.StatusServiceUnavailable,
+ recorder.Body.String(),
+ )
+ }
+ })
+ }
+ })
+
+ t.Run("bad requests", func(t *testing.T) {
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: stubResourceService{},
+ })
+
+ tests := []struct {
+ name string
+ method string
+ target string
+ body []byte
+ params gin.Params
+ call func(*BaseHandlers, *gin.Context)
+ want int
+ }{
+ {
+ name: "list invalid scope",
+ method: http.MethodGet,
+ target: "/api/resources?scope_kind=workspace",
+ call: (*BaseHandlers).ListResources,
+ want: http.StatusUnprocessableEntity,
+ },
+ {
+ name: "get missing id",
+ method: http.MethodGet,
+ target: "/api/resources/bundle.activation",
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}},
+ call: (*BaseHandlers).GetResource,
+ want: http.StatusUnprocessableEntity,
+ },
+ {
+ name: "put bad json",
+ method: http.MethodPut,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"scope":`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).PutResource,
+ want: http.StatusBadRequest,
+ },
+ {
+ name: "put invalid scope",
+ method: http.MethodPut,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"scope":{"kind":"workspace"},"spec":{"enabled":true}}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).PutResource,
+ want: http.StatusUnprocessableEntity,
+ },
+ {
+ name: "delete bad json",
+ method: http.MethodDelete,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"expected_version":`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).DeleteResource,
+ want: http.StatusBadRequest,
+ },
+ {
+ name: "delete missing expected version",
+ method: http.MethodDelete,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"expected_version":0}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).DeleteResource,
+ want: http.StatusUnprocessableEntity,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, recorder := newResourceRequestContext(t, tt.method, tt.target, tt.body, tt.params)
+ tt.call(handlers, ctx)
+ if recorder.Code != tt.want {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
+ }
+ })
+ }
+ })
+
+ t.Run("service errors", func(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ target string
+ body []byte
+ params gin.Params
+ call func(*BaseHandlers, *gin.Context)
+ want int
+ build func(error) stubResourceService
+ err error
+ }{
+ {
+ name: "list forbidden",
+ method: http.MethodGet,
+ target: "/api/resources",
+ call: (*BaseHandlers).ListResources,
+ want: http.StatusForbidden,
+ err: resources.ErrPermissionDenied,
+ build: func(err error) stubResourceService {
+ return stubResourceService{
+ ListFn: func(context.Context, resources.ResourceFilter) ([]resources.RawRecord, error) {
+ return nil, err
+ },
+ }
+ },
+ },
+ {
+ name: "get missing",
+ method: http.MethodGet,
+ target: "/api/resources/bundle.activation/demo",
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).GetResource,
+ want: http.StatusNotFound,
+ err: resources.ErrNotFound,
+ build: func(err error) stubResourceService {
+ return stubResourceService{
+ GetFn: func(context.Context, resources.ResourceKind, string) (resources.RawRecord, error) {
+ return resources.RawRecord{}, err
+ },
+ }
+ },
+ },
+ {
+ name: "put payload too large",
+ method: http.MethodPut,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).PutResource,
+ want: http.StatusRequestEntityTooLarge,
+ err: resources.ErrPayloadTooLarge,
+ build: func(err error) stubResourceService {
+ return stubResourceService{
+ PutFn: func(context.Context, resources.RawDraft) (resources.RawRecord, error) {
+ return resources.RawRecord{}, err
+ },
+ }
+ },
+ },
+ {
+ name: "delete rate limited",
+ method: http.MethodDelete,
+ target: "/api/resources/bundle.activation/demo",
+ body: []byte(`{"expected_version":3}`),
+ params: gin.Params{{Key: "kind", Value: "bundle.activation"}, {Key: "id", Value: "demo"}},
+ call: (*BaseHandlers).DeleteResource,
+ want: http.StatusTooManyRequests,
+ err: resources.ErrRateLimited,
+ build: func(err error) stubResourceService {
+ return stubResourceService{
+ DeleteFn: func(context.Context, resources.ResourceKind, string, int64) error {
+ return err
+ },
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ handlers := NewBaseHandlers(&BaseHandlerConfig{
+ TransportName: "core-test",
+ Resources: tt.build(tt.err),
+ })
+ ctx, recorder := newResourceRequestContext(t, tt.method, tt.target, tt.body, tt.params)
+ tt.call(handlers, ctx)
+ if recorder.Code != tt.want {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
+ }
+ })
+ }
+ })
+}
+
+func TestStatusForResourceRequestErrorTreatsUnknownErrorsAsBadRequests(t *testing.T) {
+ t.Parallel()
+
+ if got := statusForResourceRequestError(errors.New("boom")); got != http.StatusBadRequest {
+ t.Fatalf("statusForResourceRequestError(boom) = %d, want %d", got, http.StatusBadRequest)
+ }
+}
+
+func TestParseResourceFilterRequiresContext(t *testing.T) {
+ t.Parallel()
+
+ _, err := ParseResourceFilter(nil)
+ if err == nil {
+ t.Fatal("ParseResourceFilter(nil) error = nil, want non-nil")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("ParseResourceFilter(nil) error = %v, want ErrValidation", err)
+ }
+}
+
+func TestParseResourcePathRejectsMissingID(t *testing.T) {
+ t.Parallel()
+
+ ctx := newResourceTestContext(t, http.MethodGet, "/api/resources/bundle.activation")
+ ctx.Params = gin.Params{{Key: "kind", Value: "bundle.activation"}}
+
+ _, _, err := parseResourcePath(ctx)
+ if err == nil {
+ t.Fatal("parseResourcePath() error = nil, want non-nil")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("parseResourcePath() error = %v, want ErrValidation", err)
+ }
+}
+
+func TestResourceRecordPayloadsFromRawReturnsEmptySlice(t *testing.T) {
+ t.Parallel()
+
+ payloads := ResourceRecordPayloadsFromRaw(nil)
+ if payloads == nil {
+ t.Fatal("ResourceRecordPayloadsFromRaw(nil) = nil, want empty slice")
+ }
+ if len(payloads) != 0 {
+ t.Fatalf("len(payloads) = %d, want 0", len(payloads))
+ }
+}
+
+func newResourceTestContext(t *testing.T, method string, target string) *gin.Context {
+ t.Helper()
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequestWithContext(context.Background(), method, target, http.NoBody)
+ return ctx
+}
+
+func newResourceRequestContext(
+ t *testing.T,
+ method string,
+ target string,
+ body []byte,
+ params gin.Params,
+) (*gin.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ var reader io.Reader = http.NoBody
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+ ctx.Request = httptest.NewRequestWithContext(context.Background(), method, target, reader)
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ ctx.Params = params
+ return ctx, recorder
+}
diff --git a/internal/api/core/workspaces.go b/internal/api/core/workspaces.go
index fb07ef349..2af588fe6 100644
--- a/internal/api/core/workspaces.go
+++ b/internal/api/core/workspaces.go
@@ -41,6 +41,7 @@ func (h *BaseHandlers) CreateWorkspace(c *gin.Context) {
Name: strings.TrimSpace(req.Name),
AdditionalDirs: addDirs,
DefaultAgent: strings.TrimSpace(req.DefaultAgent),
+ EnvironmentRef: strings.TrimSpace(req.EnvironmentRef),
})
if err != nil {
h.respondError(c, StatusForWorkspaceError(err), err)
@@ -129,6 +130,10 @@ func (h *BaseHandlers) UpdateWorkspace(c *gin.Context) {
defaultAgent := strings.TrimSpace(*req.DefaultAgent)
opts.DefaultAgent = &defaultAgent
}
+ if req.EnvironmentRef != nil {
+ environmentRef := strings.TrimSpace(*req.EnvironmentRef)
+ opts.EnvironmentRef = &environmentRef
+ }
if err := h.Workspaces.Update(c.Request.Context(), workspace.ID, opts); err != nil {
h.respondError(c, StatusForWorkspaceError(err), err)
diff --git a/internal/api/httpapi/handlers.go b/internal/api/httpapi/handlers.go
index 877dde725..91101159b 100644
--- a/internal/api/httpapi/handlers.go
+++ b/internal/api/httpapi/handlers.go
@@ -5,6 +5,7 @@ import (
"log/slog"
"time"
+ "github.com/gin-gonic/gin"
"github.com/pedronauck/agh/internal/api/core"
aghconfig "github.com/pedronauck/agh/internal/config"
"github.com/pedronauck/agh/internal/memory"
@@ -16,10 +17,12 @@ type handlerConfig struct {
network core.NetworkService
networkStore core.NetworkStore
observer core.Observer
+ resources core.ResourceService
automation core.AutomationManager
bridges core.BridgeService
bundles core.BundleService
workspaces core.WorkspaceService
+ agentCatalog core.AgentCatalog
skillsRegistry core.SkillsRegistry
memoryStore *memory.Store
dreamTrigger core.DreamTrigger
@@ -32,12 +35,14 @@ type handlerConfig struct {
pollInterval time.Duration
agentLoader core.AgentLoader
httpPort int
+ resourceAuth []gin.HandlerFunc
}
// Handlers expose request/response and SSE endpoints for the AGH API.
type Handlers struct {
*core.BaseHandlers
- staticFS fs.FS
+ staticFS fs.FS
+ resourceAuth []gin.HandlerFunc
}
func newHandlers(cfg *handlerConfig) *Handlers {
@@ -62,10 +67,12 @@ func newHandlers(cfg *handlerConfig) *Handlers {
Network: cfg.network,
NetworkStore: cfg.networkStore,
Observer: cfg.observer,
+ Resources: cfg.resources,
Automation: cfg.automation,
Bridges: cfg.bridges,
Bundles: cfg.bundles,
Workspaces: cfg.workspaces,
+ AgentCatalog: cfg.agentCatalog,
SkillsRegistry: cfg.skillsRegistry,
MemoryStore: cfg.memoryStore,
DreamTrigger: cfg.dreamTrigger,
@@ -78,7 +85,8 @@ func newHandlers(cfg *handlerConfig) *Handlers {
AgentLoader: cfg.agentLoader,
HTTPPort: cfg.httpPort,
}),
- staticFS: cfg.staticFS,
+ staticFS: cfg.staticFS,
+ resourceAuth: append([]gin.HandlerFunc(nil), cfg.resourceAuth...),
}
}
@@ -93,3 +101,10 @@ func (h *Handlers) setHTTPPort(port int) {
h.SetHTTPPort(port)
}
}
+
+func (h *Handlers) resourceAuthMiddleware() []gin.HandlerFunc {
+ if h == nil || len(h.resourceAuth) == 0 {
+ return nil
+ }
+ return append([]gin.HandlerFunc(nil), h.resourceAuth...)
+}
diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go
index 1c4c93dbc..48b98df0e 100644
--- a/internal/api/httpapi/handlers_test.go
+++ b/internal/api/httpapi/handlers_test.go
@@ -309,7 +309,8 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
RegisterFn: func(_ context.Context, opts workspacepkg.RegisterOptions) (workspacepkg.Workspace, error) {
if opts.RootDir != rootDir || opts.Name != "alpha" || len(opts.AdditionalDirs) != 1 ||
opts.AdditionalDirs[0] != addDir ||
- opts.DefaultAgent != "coder" {
+ opts.DefaultAgent != "coder" ||
+ opts.EnvironmentRef != "daytona-dev" {
t.Fatalf("Register() opts = %#v", opts)
}
return workspacepkg.Workspace{
@@ -318,6 +319,7 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
AdditionalDirs: []string{addDir},
Name: "alpha",
DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
}, nil
@@ -329,10 +331,11 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
)
body, err := json.Marshal(map[string]any{
- "root_dir": rootDir,
- "name": "alpha",
- "add_dirs": []string{addDir},
- "default_agent": "coder",
+ "root_dir": rootDir,
+ "name": "alpha",
+ "add_dirs": []string{addDir},
+ "default_agent": "coder",
+ "environment_ref": "daytona-dev",
})
if err != nil {
t.Fatalf("json.Marshal(create workspace request) error = %v", err)
@@ -511,6 +514,7 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
Name: "beta",
AdditionalDirs: []string{addDir},
DefaultAgent: "reviewer",
+ EnvironmentRef: "local-dev",
}, nil
},
UpdateFn: func(_ context.Context, id string, opts workspacepkg.UpdateOptions) error {
@@ -518,7 +522,9 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
len(*opts.AdditionalDirs) != 1 ||
(*opts.AdditionalDirs)[0] != addDir ||
opts.DefaultAgent == nil ||
- *opts.DefaultAgent != "reviewer" {
+ *opts.DefaultAgent != "reviewer" ||
+ opts.EnvironmentRef == nil ||
+ *opts.EnvironmentRef != "local-dev" {
t.Fatalf("Update() id=%q opts=%#v", id, opts)
}
updated = true
@@ -531,9 +537,10 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
)
body, err := json.Marshal(map[string]any{
- "name": "beta",
- "add_dirs": []string{addDir},
- "default_agent": "reviewer",
+ "name": "beta",
+ "add_dirs": []string{addDir},
+ "default_agent": "reviewer",
+ "environment_ref": "local-dev",
})
if err != nil {
t.Fatalf("json.Marshal(update workspace request) error = %v", err)
diff --git a/internal/api/httpapi/helpers_test.go b/internal/api/httpapi/helpers_test.go
index 0f7de234e..fe7558e36 100644
--- a/internal/api/httpapi/helpers_test.go
+++ b/internal/api/httpapi/helpers_test.go
@@ -22,6 +22,7 @@ type stubSessionManager = testutil.StubSessionManager
type stubObserver = testutil.StubObserver
type stubTaskManager = testutil.StubTaskManager
type stubBridgeService = testutil.StubBridgeService
+type stubResourceService = testutil.StubResourceService
type stubWorkspaceService = testutil.StubWorkspaceService
type sseRecord = testutil.SSERecord
@@ -112,6 +113,70 @@ func newTestHandlersWithWorkspace(
return newTestHandlersWithBridges(t, manager, observer, nil, workspaces, homePaths)
}
+func newTestHandlersWithResources(
+ t *testing.T,
+ manager core.SessionManager,
+ observer core.Observer,
+ resources core.ResourceService,
+ homePaths aghconfig.HomePaths,
+) *Handlers {
+ t.Helper()
+
+ cfg := aghconfig.DefaultWithHome(homePaths)
+ cfg.HTTP.Host = "127.0.0.1"
+ cfg.HTTP.Port = 2123
+
+ return newHandlers(&handlerConfig{
+ sessions: manager,
+ tasks: stubTaskManager{},
+ observer: observer,
+ resources: resources,
+ workspaces: stubWorkspaceService{},
+ staticFS: mustStaticFS(t),
+ homePaths: homePaths,
+ config: cfg,
+ logger: discardLogger(),
+ startedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
+ now: func() time.Time { return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) },
+ pollInterval: 5 * time.Millisecond,
+ agentLoader: aghconfig.LoadAgentDef,
+ httpPort: cfg.HTTP.Port,
+ })
+}
+
+func newTestHandlersWithResourcesAndAuth(
+ t *testing.T,
+ manager core.SessionManager,
+ observer core.Observer,
+ resources core.ResourceService,
+ auth ...gin.HandlerFunc,
+) *Handlers {
+ t.Helper()
+
+ homePaths := newTestHomePaths(t)
+ cfg := aghconfig.DefaultWithHome(homePaths)
+ cfg.HTTP.Host = "127.0.0.1"
+ cfg.HTTP.Port = 2123
+
+ return newHandlers(&handlerConfig{
+ sessions: manager,
+ tasks: stubTaskManager{},
+ observer: observer,
+ resources: resources,
+ workspaces: stubWorkspaceService{},
+ staticFS: mustStaticFS(t),
+ homePaths: homePaths,
+ config: cfg,
+ logger: discardLogger(),
+ startedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
+ now: func() time.Time { return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) },
+ pollInterval: 5 * time.Millisecond,
+ agentLoader: aghconfig.LoadAgentDef,
+ httpPort: cfg.HTTP.Port,
+ resourceAuth: append([]gin.HandlerFunc(nil), auth...),
+ })
+}
+
func newTestRouter(t *testing.T, handlers *Handlers) *gin.Engine {
t.Helper()
diff --git a/internal/api/httpapi/httpapi_integration_test.go b/internal/api/httpapi/httpapi_integration_test.go
index ae9976cb5..2e61412e3 100644
--- a/internal/api/httpapi/httpapi_integration_test.go
+++ b/internal/api/httpapi/httpapi_integration_test.go
@@ -22,8 +22,10 @@ import (
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
aghconfig "github.com/pedronauck/agh/internal/config"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/store/globaldb"
@@ -191,6 +193,38 @@ func TestHTTPSessionTranscriptEndpointWithRealSessionManager(t *testing.T) {
}
}
+func TestHTTPResourceMutationRoutesRemainUnavailableWithoutOperatorAuth(t *testing.T) {
+ runtime := newIntegrationRuntime(t)
+
+ putResp := mustHTTPRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ mustURL(runtime.host, runtime.port, "/api/resources/bundle.activation/demo"),
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ nil,
+ )
+ defer func() { _ = putResp.Body.Close() }()
+ if putResp.StatusCode != http.StatusNotFound {
+ body, _ := io.ReadAll(putResp.Body)
+ t.Fatalf("PUT status = %d, want %d; body=%s", putResp.StatusCode, http.StatusNotFound, string(body))
+ }
+
+ deleteResp := mustHTTPRequest(
+ t,
+ runtime.client,
+ http.MethodDelete,
+ mustURL(runtime.host, runtime.port, "/api/resources/bundle.activation/demo"),
+ []byte(`{"expected_version":1}`),
+ nil,
+ )
+ defer func() { _ = deleteResp.Body.Close() }()
+ if deleteResp.StatusCode != http.StatusNotFound {
+ body, _ := io.ReadAll(deleteResp.Body)
+ t.Fatalf("DELETE status = %d, want %d; body=%s", deleteResp.StatusCode, http.StatusNotFound, string(body))
+ }
+}
+
func TestHTTPSessionStreamReconnectsWithLastEventID(t *testing.T) {
runtime := newIntegrationRuntime(t)
sessionID := createIntegrationSession(t, runtime)
@@ -1686,12 +1720,17 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D
t.Fatalf("workspace.NewResolver() error = %v", err)
}
driver := newIntegrationDriver(permissionWait)
+ environmentRegistry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
manager, err := session.NewManager(
session.WithHomePaths(homePaths),
session.WithWorkspaceResolver(resolver),
session.WithLogger(discardLogger()),
session.WithDriver(driver),
session.WithNotifier(fanout),
+ session.WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
@@ -1758,6 +1797,15 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D
t.Fatalf("task.NewManager() error = %v", err)
}
+ resourceKernel, err := resources.NewKernel(registry.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ resourceService, err := core.NewOperatorResourceService(&core.ResourceServiceConfig{RawStore: resourceKernel})
+ if err != nil {
+ t.Fatalf("core.NewOperatorResourceService() error = %v", err)
+ }
+
server, err := New(
WithHomePaths(homePaths),
WithConfig(&cfg),
@@ -1767,6 +1815,7 @@ func newIntegrationRuntimeWithPermissionWait(t *testing.T, permissionWait time.D
WithSessionManager(manager),
WithTaskService(taskManager),
WithObserver(observer),
+ WithResourceService(resourceService),
WithAutomation(automationManager),
WithBridgeService(bridgeService),
WithWorkspaceResolver(resolver),
diff --git a/internal/api/httpapi/resources_test.go b/internal/api/httpapi/resources_test.go
new file mode 100644
index 000000000..995bb09cf
--- /dev/null
+++ b/internal/api/httpapi/resources_test.go
@@ -0,0 +1,170 @@
+package httpapi
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pedronauck/agh/internal/api/contract"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+func TestRegisterRoutesLeavesResourceSurfaceDisabledWithoutOperatorAuth(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(t, stubSessionManager{}, stubObserver{}, stubResourceService{}, homePaths),
+ )
+
+ routes := routeSet(engine)
+ for _, route := range []string{
+ "GET /api/resources",
+ "GET /api/resources/:kind",
+ "GET /api/resources/:kind/:id",
+ "PUT /api/resources/:kind/:id",
+ "DELETE /api/resources/:kind/:id",
+ } {
+ if _, ok := routes[route]; ok {
+ t.Fatalf("route %q is registered without operator auth", route)
+ }
+ }
+}
+
+func TestRegisterRoutesExposesResourceSurfaceWhenOperatorAuthPresent(t *testing.T) {
+ t.Parallel()
+
+ var authCalls int
+ var gotDraft resources.RawDraft
+
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResourcesAndAuth(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ PutFn: func(_ context.Context, draft resources.RawDraft) (resources.RawRecord, error) {
+ gotDraft = draft
+ return resources.RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: 1,
+ Scope: draft.Scope,
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }, nil
+ },
+ },
+ func(c *gin.Context) {
+ authCalls++
+ c.Next()
+ },
+ ),
+ )
+
+ routes := routeSet(engine)
+ for _, route := range []string{
+ "GET /api/resources",
+ "GET /api/resources/:kind",
+ "GET /api/resources/:kind/:id",
+ "PUT /api/resources/:kind/:id",
+ "DELETE /api/resources/:kind/:id",
+ } {
+ if _, ok := routes[route]; !ok {
+ t.Fatalf("route %q is missing with operator auth", route)
+ }
+ }
+
+ resp := performRequest(
+ t,
+ engine,
+ http.MethodPut,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ )
+ if resp.Code != http.StatusCreated {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String())
+ }
+ if authCalls != 1 {
+ t.Fatalf("authCalls = %d, want 1", authCalls)
+ }
+ if gotDraft.Kind != resources.ResourceKind("bundle.activation") || gotDraft.ID != "demo" {
+ t.Fatalf("gotDraft identity = %#v", gotDraft)
+ }
+
+ var payload contract.ResourceResponse
+ decodeJSONResponse(t, resp, &payload)
+ if payload.Record.Version != 1 {
+ t.Fatalf("payload.Record.Version = %d, want 1", payload.Record.Version)
+ }
+}
+
+func TestResourceMutationRoutesRemainUnavailableWithoutOperatorAuth(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ called := false
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ PutFn: func(context.Context, resources.RawDraft) (resources.RawRecord, error) {
+ called = true
+ return resources.RawRecord{}, nil
+ },
+ DeleteFn: func(context.Context, resources.ResourceKind, string, int64) error {
+ called = true
+ return nil
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ putResp := performRequest(
+ t,
+ engine,
+ http.MethodPut,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ )
+ if putResp.Code != http.StatusNotFound {
+ t.Fatalf("PUT status = %d, want %d; body=%s", putResp.Code, http.StatusNotFound, putResp.Body.String())
+ }
+
+ deleteResp := performRequest(
+ t,
+ engine,
+ http.MethodDelete,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"expected_version":1}`),
+ )
+ if deleteResp.Code != http.StatusNotFound {
+ t.Fatalf("DELETE status = %d, want %d; body=%s", deleteResp.Code, http.StatusNotFound, deleteResp.Body.String())
+ }
+
+ if called {
+ t.Fatal("resource service was invoked even though resource routes are disabled")
+ }
+}
+
+func routeSet(engine *gin.Engine) map[string]struct{} {
+ routes := make(map[string]struct{}, len(engine.Routes()))
+ for _, route := range engine.Routes() {
+ routes[route.Method+" "+route.Path] = struct{}{}
+ }
+ return routes
+}
diff --git a/internal/api/httpapi/routes.go b/internal/api/httpapi/routes.go
index 1c8aa228e..d257afc33 100644
--- a/internal/api/httpapi/routes.go
+++ b/internal/api/httpapi/routes.go
@@ -16,6 +16,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) {
registerAgentRoutes(api, handlers)
registerObserveRoutes(api, handlers)
registerHookRoutes(api, handlers)
+ registerResourceRoutes(api, handlers)
registerAutomationRoutes(api, handlers)
registerTaskRoutes(api, handlers)
registerSkillRoutes(api, handlers)
@@ -93,6 +94,24 @@ func registerHookRoutes(api gin.IRouter, handlers *Handlers) {
hooksGroup.GET("/events", handlers.HookEvents)
}
+func registerResourceRoutes(api gin.IRouter, handlers *Handlers) {
+ if handlers == nil {
+ return
+ }
+
+ auth := handlers.resourceAuthMiddleware()
+ if len(auth) == 0 {
+ return
+ }
+
+ resourcesGroup := api.Group("/resources", auth...)
+ resourcesGroup.GET("", handlers.ListResources)
+ resourcesGroup.GET("/:kind", handlers.ListResources)
+ resourcesGroup.GET("/:kind/:id", handlers.GetResource)
+ resourcesGroup.PUT("/:kind/:id", handlers.PutResource)
+ resourcesGroup.DELETE("/:kind/:id", handlers.DeleteResource)
+}
+
func registerAutomationRoutes(api gin.IRouter, handlers *Handlers) {
automationGroup := api.Group("/automation")
diff --git a/internal/api/httpapi/server.go b/internal/api/httpapi/server.go
index 68b3cf2ae..57695a9a1 100644
--- a/internal/api/httpapi/server.go
+++ b/internal/api/httpapi/server.go
@@ -50,10 +50,13 @@ type Server struct {
bridges core.BridgeService
bundles core.BundleService
workspaces core.WorkspaceService
+ agentCatalog core.AgentCatalog
skillsRegistry core.SkillsRegistry
memoryStore *memory.Store
dreamTrigger core.DreamTrigger
agentLoader core.AgentLoader
+ resources core.ResourceService
+ resourceAuth []gin.HandlerFunc
engine *gin.Engine
handlers *Handlers
@@ -201,6 +204,13 @@ func WithSkillsRegistry(registry core.SkillsRegistry) Option {
}
}
+// WithAgentCatalog injects the projected resource-backed agent catalog.
+func WithAgentCatalog(catalog core.AgentCatalog) Option {
+ return func(server *Server) {
+ server.agentCatalog = catalog
+ }
+}
+
// WithDreamTrigger injects the dream-consolidation trigger surfaced by the daemon.
func WithDreamTrigger(trigger core.DreamTrigger) Option {
return func(server *Server) {
@@ -215,6 +225,20 @@ func WithAgentLoader(loader core.AgentLoader) Option {
}
}
+// WithResourceService injects the shared operator-facing desired-state resource service.
+func WithResourceService(service core.ResourceService) Option {
+ return func(server *Server) {
+ server.resources = service
+ }
+}
+
+// WithResourceOperatorAuth gates HTTP resource routes behind explicit operator auth middleware.
+func WithResourceOperatorAuth(middleware ...gin.HandlerFunc) Option {
+ return func(server *Server) {
+ server.resourceAuth = append([]gin.HandlerFunc(nil), middleware...)
+ }
+}
+
// WithEngine overrides the Gin engine used by the server, mainly for tests.
func WithEngine(engine *gin.Engine) Option {
return func(server *Server) {
@@ -304,6 +328,8 @@ func (s *Server) applyDefaults() {
func (s *Server) validateRequired() error {
switch {
+ case len(s.resourceAuth) > 0 && s.resources == nil:
+ return errors.New("httpapi: resource service is required when resource operator auth is configured")
case s.sessions == nil:
return errors.New("httpapi: session manager is required")
case s.tasks == nil:
@@ -345,10 +371,12 @@ func (s *Server) handlerConfig(staticFS fs.FS) *handlerConfig {
network: s.network,
networkStore: s.networkStore,
observer: s.observer,
+ resources: s.resources,
automation: s.automation,
bridges: s.bridges,
bundles: s.bundles,
workspaces: s.workspaces,
+ agentCatalog: s.agentCatalog,
skillsRegistry: s.skillsRegistry,
memoryStore: s.memoryStore,
dreamTrigger: s.dreamTrigger,
@@ -361,6 +389,7 @@ func (s *Server) handlerConfig(staticFS fs.FS) *handlerConfig {
pollInterval: s.pollInterval,
agentLoader: s.agentLoader,
httpPort: s.port,
+ resourceAuth: append([]gin.HandlerFunc(nil), s.resourceAuth...),
}
}
diff --git a/internal/api/httpapi/server_test.go b/internal/api/httpapi/server_test.go
index 3409f0762..289060900 100644
--- a/internal/api/httpapi/server_test.go
+++ b/internal/api/httpapi/server_test.go
@@ -109,6 +109,23 @@ func TestNewRequiresSessionManagerTaskServiceObserverAndWorkspaceResolver(t *tes
}
}
+func TestNewRejectsResourceAuthWithoutResourceService(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ _, err := New(
+ WithHomePaths(homePaths),
+ WithSessionManager(stubSessionManager{}),
+ WithTaskService(stubTaskManager{}),
+ WithObserver(stubObserver{}),
+ WithWorkspaceResolver(stubWorkspaceService{}),
+ WithResourceOperatorAuth(func(*gin.Context) {}),
+ )
+ if err == nil {
+ t.Fatal("New() with resource auth and no resource service error = nil, want non-nil")
+ }
+}
+
func TestServerStartAndShutdownServeRequests(t *testing.T) {
homePaths := newTestHomePaths(t)
cfg := aghconfig.DefaultWithHome(homePaths)
diff --git a/internal/api/spec/resources_test.go b/internal/api/spec/resources_test.go
new file mode 100644
index 000000000..5b432cbfd
--- /dev/null
+++ b/internal/api/spec/resources_test.go
@@ -0,0 +1,65 @@
+package spec
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestResourceOperationsSupportHTTPAndUDS(t *testing.T) {
+ t.Parallel()
+
+ want := map[string]string{
+ "GET /api/resources": "listResources",
+ "GET /api/resources/{kind}": "listResourcesByKind",
+ "GET /api/resources/{kind}/{id}": "getResource",
+ "PUT /api/resources/{kind}/{id}": "putResource",
+ "DELETE /api/resources/{kind}/{id}": "deleteResource",
+ }
+
+ seen := make(map[string]OperationSpec, len(want))
+ for _, op := range Operations() {
+ key := op.Method + " " + op.Path
+ if _, ok := want[key]; ok {
+ seen[key] = op
+ }
+ }
+
+ if len(seen) != len(want) {
+ t.Fatalf("resource operations found = %d, want %d", len(seen), len(want))
+ }
+
+ for key, op := range seen {
+ if op.OperationID != want[key] {
+ t.Fatalf("%s operation_id = %q, want %q", key, op.OperationID, want[key])
+ }
+ if !slices.Equal(op.Transports, []Transport{TransportHTTP, TransportUDS}) {
+ t.Fatalf("%s transports = %#v, want [http uds]", key, op.Transports)
+ }
+ }
+}
+
+func TestDocumentDescribesResourceCRUDSchemas(t *testing.T) {
+ t.Parallel()
+
+ doc, err := Document()
+ if err != nil {
+ t.Fatalf("Document() error = %v", err)
+ }
+
+ listResources := operationFor(t, doc, "/api/resources", "GET")
+ assertParameter(t, listResources, "kind", "query", false)
+ assertParameter(t, listResources, "scope_kind", "query", false)
+ assertParameter(t, listResources, "limit", "query", false)
+
+ putResource := operationFor(t, doc, "/api/resources/{kind}/{id}", "PUT")
+ putSchema := jsonRequestSchema(t, putResource)
+ assertRequired(t, putSchema, "scope", "spec")
+ assertNotRequired(t, putSchema, "expected_version")
+ scopeSchema := propertySchema(t, putSchema, "scope")
+ assertEnumValues(t, propertySchema(t, scopeSchema, "kind"), "global", "workspace")
+
+ deleteResource := operationFor(t, doc, "/api/resources/{kind}/{id}", "DELETE")
+ deleteSchema := jsonRequestSchema(t, deleteResource)
+ assertRequired(t, deleteSchema, "expected_version")
+ assertNotRequired(t, deleteSchema, "scope", "spec")
+}
diff --git a/internal/api/spec/spec.go b/internal/api/spec/spec.go
index 0eda87d99..09dbe8303 100644
--- a/internal/api/spec/spec.go
+++ b/internal/api/spec/spec.go
@@ -20,6 +20,7 @@ import (
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
"github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/memory"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store"
taskpkg "github.com/pedronauck/agh/internal/task"
@@ -55,6 +56,7 @@ var schemaEnumValues = map[reflect.Type][]string{
reflect.TypeFor[hooks.HookSource](): hookSourceValues(),
reflect.TypeFor[memory.Type](): memoryTypeValues(),
reflect.TypeFor[memory.Scope](): memoryScopeValues(),
+ reflect.TypeFor[resources.ResourceScopeKind](): resourceScopeKindValues(),
reflect.TypeFor[bridgepkg.Scope](): bridgeScopeValues(),
reflect.TypeFor[bridgepkg.BridgeInstanceSource](): bridgeInstanceSourceValues(),
reflect.TypeFor[bridgepkg.BridgeStatus](): bridgeStatusValues(),
@@ -140,6 +142,7 @@ func Document() (*openapi3.T, error) {
{Name: "hooks"},
{Name: "memory"},
{Name: "observe"},
+ {Name: "resources"},
{Name: "sessions"},
{Name: "skills"},
{Name: "tasks"},
@@ -163,6 +166,118 @@ func Document() (*openapi3.T, error) {
}
var operationRegistry = []OperationSpec{
+ {
+ Method: "GET",
+ Path: "/api/resources",
+ OperationID: "listResources",
+ Summary: "List desired-state resources on the local operator control plane",
+ Tags: []string{"resources"},
+ Transports: []Transport{TransportHTTP, TransportUDS},
+ Parameters: []ParameterSpec{
+ queryParam("kind", "Filter by resource kind", false),
+ enumQueryParam("scope_kind", "Filter by resource scope kind", resourceScopeKindValues()),
+ queryParam("scope_id", "Filter by workspace scope id", false),
+ queryParam("owner_kind", "Filter by stamped owner kind", false),
+ queryParam("owner_id", "Filter by stamped owner id", false),
+ queryParam("source_kind", "Filter by stamped source kind", false),
+ queryParam("source_id", "Filter by stamped source id", false),
+ intQueryParam("limit", "Maximum number of records to return"),
+ },
+ Responses: []ResponseSpec{
+ {Status: 200, Description: "OK", Body: contract.ResourcesResponse{}},
+ {Status: 403, Description: "Forbidden", Body: contract.ErrorPayload{}},
+ {Status: 422, Description: "Invalid resource filter", Body: contract.ErrorPayload{}},
+ {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}},
+ },
+ },
+ {
+ Method: "GET",
+ Path: "/api/resources/{kind}",
+ OperationID: "listResourcesByKind",
+ Summary: "List one desired-state resource kind on the local operator control plane",
+ Tags: []string{"resources"},
+ Transports: []Transport{TransportHTTP, TransportUDS},
+ Parameters: []ParameterSpec{
+ pathParam("kind", "Resource kind"),
+ enumQueryParam("scope_kind", "Filter by resource scope kind", resourceScopeKindValues()),
+ queryParam("scope_id", "Filter by workspace scope id", false),
+ queryParam("owner_kind", "Filter by stamped owner kind", false),
+ queryParam("owner_id", "Filter by stamped owner id", false),
+ queryParam("source_kind", "Filter by stamped source kind", false),
+ queryParam("source_id", "Filter by stamped source id", false),
+ intQueryParam("limit", "Maximum number of records to return"),
+ },
+ Responses: []ResponseSpec{
+ {Status: 200, Description: "OK", Body: contract.ResourcesResponse{}},
+ {Status: 403, Description: "Forbidden", Body: contract.ErrorPayload{}},
+ {Status: 422, Description: "Invalid resource filter", Body: contract.ErrorPayload{}},
+ {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}},
+ },
+ },
+ {
+ Method: "GET",
+ Path: "/api/resources/{kind}/{id}",
+ OperationID: "getResource",
+ Summary: "Read one desired-state resource on the local operator control plane",
+ Tags: []string{"resources"},
+ Transports: []Transport{TransportHTTP, TransportUDS},
+ Parameters: []ParameterSpec{
+ pathParam("kind", "Resource kind"),
+ pathParam("id", "Resource id"),
+ },
+ Responses: []ResponseSpec{
+ {Status: 200, Description: "OK", Body: contract.ResourceResponse{}},
+ {Status: 404, Description: "Resource not found", Body: contract.ErrorPayload{}},
+ {Status: 403, Description: "Forbidden", Body: contract.ErrorPayload{}},
+ {Status: 422, Description: "Invalid resource identifier", Body: contract.ErrorPayload{}},
+ {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}},
+ },
+ },
+ {
+ Method: "PUT",
+ Path: "/api/resources/{kind}/{id}",
+ OperationID: "putResource",
+ Summary: "Create or replace one desired-state resource on the local operator control plane",
+ Tags: []string{"resources"},
+ Transports: []Transport{TransportHTTP, TransportUDS},
+ Parameters: []ParameterSpec{
+ pathParam("kind", "Resource kind"),
+ pathParam("id", "Resource id"),
+ },
+ RequestBody: contract.PutResourceRequest{},
+ Responses: []ResponseSpec{
+ {Status: 200, Description: "Updated", Body: contract.ResourceResponse{}},
+ {Status: 201, Description: "Created", Body: contract.ResourceResponse{}},
+ {Status: 403, Description: "Forbidden", Body: contract.ErrorPayload{}},
+ {Status: 409, Description: "Conflict", Body: contract.ErrorPayload{}},
+ {Status: 413, Description: "Payload too large", Body: contract.ErrorPayload{}},
+ {Status: 422, Description: "Invalid resource payload", Body: contract.ErrorPayload{}},
+ {Status: 429, Description: "Rate limited", Body: contract.ErrorPayload{}},
+ {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}},
+ },
+ },
+ {
+ Method: "DELETE",
+ Path: "/api/resources/{kind}/{id}",
+ OperationID: "deleteResource",
+ Summary: "Delete one desired-state resource on the local operator control plane",
+ Tags: []string{"resources"},
+ Transports: []Transport{TransportHTTP, TransportUDS},
+ Parameters: []ParameterSpec{
+ pathParam("kind", "Resource kind"),
+ pathParam("id", "Resource id"),
+ },
+ RequestBody: contract.DeleteResourceRequest{},
+ Responses: []ResponseSpec{
+ {Status: 204, Description: "Deleted"},
+ {Status: 403, Description: "Forbidden", Body: contract.ErrorPayload{}},
+ {Status: 404, Description: "Resource not found", Body: contract.ErrorPayload{}},
+ {Status: 409, Description: "Conflict", Body: contract.ErrorPayload{}},
+ {Status: 422, Description: "Invalid delete request", Body: contract.ErrorPayload{}},
+ {Status: 429, Description: "Rate limited", Body: contract.ErrorPayload{}},
+ {Status: 500, Description: "Internal server error", Body: contract.ErrorPayload{}},
+ },
+ },
{
Method: "GET",
Path: "/api/agents",
@@ -1907,6 +2022,13 @@ func buildOperation(schemas openapi3.Schemas, spec OperationSpec) (*openapi3.Ope
return operation, nil
}
+func resourceScopeKindValues() []string {
+ return []string{
+ string(resources.ResourceScopeKindGlobal),
+ string(resources.ResourceScopeKindWorkspace),
+ }
+}
+
func schemaRefForValue(value any, schemas openapi3.Schemas) (*openapi3.SchemaRef, error) {
var rootType reflect.Type
if value != nil {
diff --git a/internal/api/testutil/apitest.go b/internal/api/testutil/apitest.go
index 16558ac51..c7c0ec9a4 100644
--- a/internal/api/testutil/apitest.go
+++ b/internal/api/testutil/apitest.go
@@ -24,6 +24,7 @@ import (
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/network"
"github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
@@ -211,6 +212,68 @@ type StubTaskManager struct {
var _ core.TaskService = (*StubTaskManager)(nil)
+type StubResourceService struct {
+ ListFn func(context.Context, resources.ResourceFilter) ([]resources.RawRecord, error)
+ GetFn func(context.Context, resources.ResourceKind, string) (resources.RawRecord, error)
+ PutFn func(context.Context, resources.RawDraft) (resources.RawRecord, error)
+ DeleteFn func(context.Context, resources.ResourceKind, string, int64) error
+}
+
+var _ core.ResourceService = (*StubResourceService)(nil)
+
+func (s StubResourceService) List(
+ ctx context.Context,
+ filter resources.ResourceFilter,
+) ([]resources.RawRecord, error) {
+ if s.ListFn != nil {
+ return s.ListFn(ctx, filter)
+ }
+ return nil, nil
+}
+
+func (s StubResourceService) Get(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+) (resources.RawRecord, error) {
+ if s.GetFn != nil {
+ return s.GetFn(ctx, kind, id)
+ }
+ return resources.RawRecord{}, resources.ErrNotFound
+}
+
+func (s StubResourceService) Put(
+ ctx context.Context,
+ draft resources.RawDraft,
+) (resources.RawRecord, error) {
+ if s.PutFn != nil {
+ return s.PutFn(ctx, draft)
+ }
+ return resources.RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: 1,
+ Scope: draft.Scope,
+ Owner: resources.ResourceOwner{Kind: "daemon", ID: "daemon-control"},
+ Source: resources.ResourceSource{Kind: "daemon", ID: "system"},
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
+ }, nil
+}
+
+func (s StubResourceService) Delete(
+ ctx context.Context,
+ kind resources.ResourceKind,
+ id string,
+ expectedVersion int64,
+) error {
+ if s.DeleteFn != nil {
+ return s.DeleteFn(ctx, kind, id, expectedVersion)
+ }
+ return nil
+}
+
func (s StubAutomationManager) ListJobs(
ctx context.Context,
query automationpkg.JobListQuery,
diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go
index 06aad6e3f..140f90cd5 100644
--- a/internal/api/udsapi/handlers_test.go
+++ b/internal/api/udsapi/handlers_test.go
@@ -85,6 +85,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) {
"DELETE /api/bridges/:id/secret-bindings/:binding_name",
"DELETE /api/bundles/activations/:id",
"DELETE /api/memory/:filename",
+ "DELETE /api/resources/:kind/:id",
"DELETE /api/sessions/:id",
"DELETE /api/tasks/:id/dependencies/:depends_on_id",
"DELETE /api/workspaces/:id",
@@ -126,6 +127,9 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) {
"GET /api/observe/events",
"GET /api/observe/events/stream",
"GET /api/observe/health",
+ "GET /api/resources",
+ "GET /api/resources/:kind",
+ "GET /api/resources/:kind/:id",
"GET /api/sessions",
"GET /api/sessions/:id",
"GET /api/sessions/:id/events",
@@ -183,6 +187,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) {
"POST /api/workspaces/resolve",
"PUT /api/bridges/:id/secret-bindings/:binding_name",
"PUT /api/memory/:filename",
+ "PUT /api/resources/:kind/:id",
}
sort.Strings(want)
@@ -381,7 +386,8 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
RegisterFn: func(_ context.Context, opts workspacepkg.RegisterOptions) (workspacepkg.Workspace, error) {
if opts.RootDir != rootDir || opts.Name != "alpha" || len(opts.AdditionalDirs) != 1 ||
opts.AdditionalDirs[0] != addDir ||
- opts.DefaultAgent != "coder" {
+ opts.DefaultAgent != "coder" ||
+ opts.EnvironmentRef != "daytona-dev" {
t.Fatalf("Register() opts = %#v", opts)
}
return workspacepkg.Workspace{
@@ -390,6 +396,7 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
AdditionalDirs: []string{addDir},
Name: "alpha",
DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
}, nil
@@ -401,10 +408,11 @@ func TestCreateWorkspaceHandlerRegistersWorkspace(t *testing.T) {
)
body := mustJSONBody(t, map[string]any{
- "root_dir": rootDir,
- "name": "alpha",
- "add_dirs": []string{addDir},
- "default_agent": "coder",
+ "root_dir": rootDir,
+ "name": "alpha",
+ "add_dirs": []string{addDir},
+ "default_agent": "coder",
+ "environment_ref": "daytona-dev",
})
recorder := performRequest(t, engine, http.MethodPost, "/api/workspaces", body)
if recorder.Code != http.StatusCreated {
@@ -532,6 +540,7 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
Name: "beta",
AdditionalDirs: []string{addDir},
DefaultAgent: "reviewer",
+ EnvironmentRef: "local-dev",
}, nil
},
UpdateFn: func(_ context.Context, id string, opts workspacepkg.UpdateOptions) error {
@@ -539,7 +548,9 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
len(*opts.AdditionalDirs) != 1 ||
(*opts.AdditionalDirs)[0] != addDir ||
opts.DefaultAgent == nil ||
- *opts.DefaultAgent != "reviewer" {
+ *opts.DefaultAgent != "reviewer" ||
+ opts.EnvironmentRef == nil ||
+ *opts.EnvironmentRef != "local-dev" {
t.Fatalf("Update() id=%q opts=%#v", id, opts)
}
updated = true
@@ -552,9 +563,10 @@ func TestUpdateWorkspaceHandlerUpdatesWorkspace(t *testing.T) {
)
body := mustJSONBody(t, map[string]any{
- "name": "beta",
- "add_dirs": []string{addDir},
- "default_agent": "reviewer",
+ "name": "beta",
+ "add_dirs": []string{addDir},
+ "default_agent": "reviewer",
+ "environment_ref": "local-dev",
})
recorder := performRequest(t, engine, http.MethodPatch, "/api/workspaces/ws_alpha", body)
if recorder.Code != http.StatusOK {
diff --git a/internal/api/udsapi/helpers_test.go b/internal/api/udsapi/helpers_test.go
index 02d1c343c..80545cd99 100644
--- a/internal/api/udsapi/helpers_test.go
+++ b/internal/api/udsapi/helpers_test.go
@@ -28,6 +28,7 @@ type stubObserver = testutil.StubObserver
type stubTaskManager = testutil.StubTaskManager
type stubBridgeService = testutil.StubBridgeService
type stubNetworkService = testutil.StubNetworkService
+type stubResourceService = testutil.StubResourceService
type stubWorkspaceService = testutil.StubWorkspaceService
type stubSkillsRegistry = testutil.StubSkillsRegistry
type sseRecord = testutil.SSERecord
@@ -128,6 +129,31 @@ func newTestHandlersWithWorkspace(
return newTestHandlersWithBridges(t, manager, observer, nil, workspaces, homePaths)
}
+func newTestHandlersWithResources(
+ t *testing.T,
+ manager core.SessionManager,
+ observer core.Observer,
+ resources core.ResourceService,
+ homePaths aghconfig.HomePaths,
+) *Handlers {
+ t.Helper()
+
+ return newHandlers(&handlerConfig{
+ sessions: manager,
+ tasks: stubTaskManager{},
+ observer: observer,
+ resources: resources,
+ workspaces: stubWorkspaceService{},
+ homePaths: homePaths,
+ config: aghconfig.DefaultWithHome(homePaths),
+ logger: discardLogger(),
+ startedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
+ now: func() time.Time { return time.Date(2026, 4, 3, 12, 0, 1, 0, time.UTC) },
+ pollInterval: 5 * time.Millisecond,
+ agentLoader: aghconfig.LoadAgentDef,
+ })
+}
+
func newTestRouter(t *testing.T, handlers *Handlers) *gin.Engine {
t.Helper()
diff --git a/internal/api/udsapi/resources_test.go b/internal/api/udsapi/resources_test.go
new file mode 100644
index 000000000..770444d40
--- /dev/null
+++ b/internal/api/udsapi/resources_test.go
@@ -0,0 +1,362 @@
+package udsapi
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pedronauck/agh/internal/api/contract"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+func TestListResourcesHandlerPreservesFilterSemantics(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ var gotFilter resources.ResourceFilter
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ ListFn: func(_ context.Context, filter resources.ResourceFilter) ([]resources.RawRecord, error) {
+ gotFilter = filter
+ return []resources.RawRecord{
+ {
+ Kind: resources.ResourceKind("bundle.activation"),
+ ID: "bundle-1",
+ Version: 3,
+ Scope: resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-alpha",
+ },
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "system",
+ },
+ SpecJSON: []byte(`{"enabled":true}`),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ },
+ }, nil
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ resp := performRequest(
+ t,
+ engine,
+ http.MethodGet,
+ "/api/resources/bundle.activation?scope_kind=workspace&scope_id=ws-alpha&owner_kind=daemon&owner_id=daemon-control&source_kind=daemon&source_id=system&limit=7",
+ nil,
+ )
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+
+ if gotFilter.Kind != resources.ResourceKind("bundle.activation") || gotFilter.Limit != 7 {
+ t.Fatalf("gotFilter = %#v", gotFilter)
+ }
+ if gotFilter.Scope == nil || gotFilter.Scope.Kind != resources.ResourceScopeKindWorkspace ||
+ gotFilter.Scope.ID != "ws-alpha" {
+ t.Fatalf("gotFilter.Scope = %#v, want workspace ws-alpha", gotFilter.Scope)
+ }
+ if gotFilter.Owner == nil || gotFilter.Owner.Kind != resources.ResourceOwnerKind("daemon") ||
+ gotFilter.Owner.ID != "daemon-control" {
+ t.Fatalf("gotFilter.Owner = %#v", gotFilter.Owner)
+ }
+ if gotFilter.Source == nil || gotFilter.Source.Kind != resources.ResourceSourceKind("daemon") ||
+ gotFilter.Source.ID != "system" {
+ t.Fatalf("gotFilter.Source = %#v", gotFilter.Source)
+ }
+
+ var payload contract.ResourcesResponse
+ decodeJSONResponse(t, resp, &payload)
+ if len(payload.Records) != 1 || payload.Records[0].ID != "bundle-1" {
+ t.Fatalf("payload.Records = %#v, want bundle-1", payload.Records)
+ }
+}
+
+func TestGetResourceHandlerPreservesKindAndID(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ var gotKind resources.ResourceKind
+ var gotID string
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ GetFn: func(_ context.Context, kind resources.ResourceKind, id string) (resources.RawRecord, error) {
+ gotKind = kind
+ gotID = id
+ return resources.RawRecord{
+ Kind: kind,
+ ID: id,
+ Version: 5,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ SpecJSON: []byte(`{"enabled":true}`),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }, nil
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ resp := performRequest(t, engine, http.MethodGet, "/api/resources/bridge.instance/bridge-1", nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ if gotKind != resources.ResourceKind("bridge.instance") || gotID != "bridge-1" {
+ t.Fatalf("Get() arguments = kind:%q id:%q", gotKind, gotID)
+ }
+}
+
+func TestPutResourceHandlerPreservesExpectedVersionAndStatusSemantics(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ body []byte
+ wantStatus int
+ wantVersion int64
+ wantScopeKind resources.ResourceScopeKind
+ wantScopeID string
+ wantExpectedVer int64
+ }{
+ {
+ name: "create",
+ body: []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ wantStatus: http.StatusCreated,
+ wantVersion: 1,
+ wantScopeKind: resources.ResourceScopeKindGlobal,
+ wantExpectedVer: 0,
+ },
+ {
+ name: "update",
+ body: []byte(
+ `{"scope":{"kind":"workspace","id":"ws-alpha"},"expected_version":4,"spec":{"enabled":false}}`,
+ ),
+ wantStatus: http.StatusOK,
+ wantVersion: 5,
+ wantScopeKind: resources.ResourceScopeKindWorkspace,
+ wantScopeID: "ws-alpha",
+ wantExpectedVer: 4,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ homePaths := newTestHomePaths(t)
+ var gotDraft resources.RawDraft
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ PutFn: func(_ context.Context, draft resources.RawDraft) (resources.RawRecord, error) {
+ gotDraft = draft
+ return resources.RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: tt.wantVersion,
+ Scope: draft.Scope,
+ Owner: resources.ResourceOwner{
+ Kind: resources.ResourceOwnerKind("daemon"),
+ ID: "daemon-control",
+ },
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "system",
+ },
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ }, nil
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ resp := performRequest(t, engine, http.MethodPut, "/api/resources/bundle.activation/demo", tt.body)
+ if resp.Code != tt.wantStatus {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, tt.wantStatus, resp.Body.String())
+ }
+ if gotDraft.Kind != resources.ResourceKind("bundle.activation") || gotDraft.ID != "demo" {
+ t.Fatalf("gotDraft identity = %#v", gotDraft)
+ }
+ if gotDraft.Scope.Kind != tt.wantScopeKind || gotDraft.Scope.ID != tt.wantScopeID {
+ t.Fatalf("gotDraft.Scope = %#v, want %q/%q", gotDraft.Scope, tt.wantScopeKind, tt.wantScopeID)
+ }
+ if gotDraft.ExpectedVersion != tt.wantExpectedVer {
+ t.Fatalf("gotDraft.ExpectedVersion = %d, want %d", gotDraft.ExpectedVersion, tt.wantExpectedVer)
+ }
+
+ var payload contract.ResourceResponse
+ decodeJSONResponse(t, resp, &payload)
+ if payload.Record.Version != tt.wantVersion {
+ t.Fatalf("payload.Record.Version = %d, want %d", payload.Record.Version, tt.wantVersion)
+ }
+ })
+ }
+}
+
+func TestPutResourceHandlerMapsResourceErrors(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ err error
+ want int
+ }{
+ {
+ name: "invalid spec",
+ err: fmt.Errorf("codec validation: %w", resources.ErrValidation),
+ want: http.StatusUnprocessableEntity,
+ },
+ {name: "stale version", err: fmt.Errorf("cas conflict: %w", resources.ErrConflict), want: http.StatusConflict},
+ {
+ name: "payload too large",
+ err: fmt.Errorf("payload ceiling: %w", resources.ErrPayloadTooLarge),
+ want: http.StatusRequestEntityTooLarge,
+ },
+ {
+ name: "rate limited",
+ err: fmt.Errorf("limiter: %w", resources.ErrRateLimited),
+ want: http.StatusTooManyRequests,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ homePaths := newTestHomePaths(t)
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ PutFn: func(context.Context, resources.RawDraft) (resources.RawRecord, error) {
+ return resources.RawRecord{}, tt.err
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ resp := performRequest(
+ t,
+ engine,
+ http.MethodPut,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ )
+ if resp.Code != tt.want {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, tt.want, resp.Body.String())
+ }
+ })
+ }
+}
+
+func TestDeleteResourceHandlerPreservesExpectedVersionAndMapsConflict(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ var gotKind resources.ResourceKind
+ var gotID string
+ var gotExpectedVersion int64
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(
+ t,
+ stubSessionManager{},
+ stubObserver{},
+ stubResourceService{
+ DeleteFn: func(_ context.Context, kind resources.ResourceKind, id string, expectedVersion int64) error {
+ gotKind = kind
+ gotID = id
+ gotExpectedVersion = expectedVersion
+ return fmt.Errorf("cas conflict: %w", resources.ErrConflict)
+ },
+ },
+ homePaths,
+ ),
+ )
+
+ resp := performRequest(
+ t,
+ engine,
+ http.MethodDelete,
+ "/api/resources/bundle.activation/demo",
+ []byte(`{"expected_version":2}`),
+ )
+ if resp.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusConflict, resp.Body.String())
+ }
+ if gotKind != resources.ResourceKind("bundle.activation") || gotID != "demo" || gotExpectedVersion != 2 {
+ t.Fatalf("Delete() arguments = kind:%q id:%q expected_version:%d", gotKind, gotID, gotExpectedVersion)
+ }
+}
+
+func TestRegisterRoutesKeepsOperationalRuntimeEndpointsFamilySpecific(t *testing.T) {
+ t.Parallel()
+
+ homePaths := newTestHomePaths(t)
+ engine := newTestRouter(
+ t,
+ newTestHandlersWithResources(t, stubSessionManager{}, stubObserver{}, stubResourceService{}, homePaths),
+ )
+
+ routes := udsRouteSet(engine)
+ for _, route := range []string{
+ "GET /api/hooks/runs",
+ "GET /api/bridges/health/stream",
+ "POST /api/bridges/:id/test-delivery",
+ } {
+ if _, ok := routes[route]; !ok {
+ t.Fatalf("expected family-specific runtime route %q to remain registered", route)
+ }
+ }
+ for _, route := range []string{
+ "GET /api/resources/:kind/:id/runs",
+ "GET /api/resources/:kind/:id/health",
+ "POST /api/resources/:kind/:id/test-delivery",
+ } {
+ if _, ok := routes[route]; ok {
+ t.Fatalf("unexpected generic runtime route %q is registered", route)
+ }
+ }
+}
+
+func udsRouteSet(engine *gin.Engine) map[string]struct{} {
+ routes := make(map[string]struct{}, len(engine.Routes()))
+ for _, route := range engine.Routes() {
+ routes[route.Method+" "+route.Path] = struct{}{}
+ }
+ return routes
+}
diff --git a/internal/api/udsapi/routes.go b/internal/api/udsapi/routes.go
index 427ffa1a9..57102807f 100644
--- a/internal/api/udsapi/routes.go
+++ b/internal/api/udsapi/routes.go
@@ -12,6 +12,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) {
registerAgentRoutes(api, handlers)
registerObserveRoutes(api, handlers)
registerHookRoutes(api, handlers)
+ registerResourceRoutes(api, handlers)
registerAutomationRoutes(api, handlers)
registerTaskRoutes(api, handlers)
registerTaskRunRoutes(api, handlers)
@@ -98,6 +99,17 @@ func registerHookRoutes(api gin.IRouter, handlers *Handlers) {
}
}
+func registerResourceRoutes(api gin.IRouter, handlers *Handlers) {
+ resourcesGroup := api.Group("/resources")
+ {
+ resourcesGroup.GET("", handlers.ListResources)
+ resourcesGroup.GET("/:kind", handlers.ListResources)
+ resourcesGroup.GET("/:kind/:id", handlers.GetResource)
+ resourcesGroup.PUT("/:kind/:id", handlers.PutResource)
+ resourcesGroup.DELETE("/:kind/:id", handlers.DeleteResource)
+ }
+}
+
func registerAutomationRoutes(api gin.IRouter, handlers *Handlers) {
automationGroup := api.Group("/automation")
{
diff --git a/internal/api/udsapi/server.go b/internal/api/udsapi/server.go
index 75b094c26..d1c310d0e 100644
--- a/internal/api/udsapi/server.go
+++ b/internal/api/udsapi/server.go
@@ -62,10 +62,12 @@ type Server struct {
network core.NetworkService
networkStore core.NetworkStore
observer core.Observer
+ resources core.ResourceService
automation core.AutomationManager
bridges core.BridgeService
bundles core.BundleService
workspaces core.WorkspaceService
+ agentCatalog core.AgentCatalog
skillsRegistry core.SkillsRegistry
memoryStore *memory.Store
dreamTrigger core.DreamTrigger
@@ -88,10 +90,12 @@ type handlerConfig struct {
network core.NetworkService
networkStore core.NetworkStore
observer core.Observer
+ resources core.ResourceService
automation core.AutomationManager
bridges core.BridgeService
bundles core.BundleService
workspaces core.WorkspaceService
+ agentCatalog core.AgentCatalog
skillsRegistry core.SkillsRegistry
memoryStore *memory.Store
dreamTrigger core.DreamTrigger
@@ -197,6 +201,13 @@ func WithObserver(observer core.Observer) Option {
}
}
+// WithResourceService injects the shared operator-facing desired-state resource service.
+func WithResourceService(service core.ResourceService) Option {
+ return func(server *Server) {
+ server.resources = service
+ }
+}
+
// WithAutomation injects the daemon-owned automation manager.
func WithAutomation(manager core.AutomationManager) Option {
return func(server *Server) {
@@ -239,6 +250,13 @@ func WithSkillsRegistry(registry core.SkillsRegistry) Option {
}
}
+// WithAgentCatalog injects the projected resource-backed agent catalog.
+func WithAgentCatalog(catalog core.AgentCatalog) Option {
+ return func(server *Server) {
+ server.agentCatalog = catalog
+ }
+}
+
// WithDreamTrigger injects the dream-consolidation trigger surfaced by the daemon.
func WithDreamTrigger(trigger core.DreamTrigger) Option {
return func(server *Server) {
@@ -380,10 +398,12 @@ func (s *Server) handlerConfig() *handlerConfig {
network: s.network,
networkStore: s.networkStore,
observer: s.observer,
+ resources: s.resources,
automation: s.automation,
bridges: s.bridges,
bundles: s.bundles,
workspaces: s.workspaces,
+ agentCatalog: s.agentCatalog,
skillsRegistry: s.skillsRegistry,
memoryStore: s.memoryStore,
dreamTrigger: s.dreamTrigger,
@@ -598,10 +618,12 @@ func newHandlers(cfg *handlerConfig) *Handlers {
Network: cfg.network,
NetworkStore: cfg.networkStore,
Observer: cfg.observer,
+ Resources: cfg.resources,
Automation: cfg.automation,
Bridges: cfg.bridges,
Bundles: cfg.bundles,
Workspaces: cfg.workspaces,
+ AgentCatalog: cfg.agentCatalog,
SkillsRegistry: cfg.skillsRegistry,
MemoryStore: cfg.memoryStore,
DreamTrigger: cfg.dreamTrigger,
diff --git a/internal/api/udsapi/udsapi_integration_test.go b/internal/api/udsapi/udsapi_integration_test.go
index 8f3c8de75..78e778d47 100644
--- a/internal/api/udsapi/udsapi_integration_test.go
+++ b/internal/api/udsapi/udsapi_integration_test.go
@@ -22,11 +22,14 @@ import (
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
aghconfig "github.com/pedronauck/agh/internal/config"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store/globaldb"
taskpkg "github.com/pedronauck/agh/internal/task"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -150,6 +153,228 @@ func TestUDSMemoryRoundTripAndConsolidate(t *testing.T) {
}
}
+func TestUDSResourceCRUDRoundTrip(t *testing.T) {
+ runtime := newIntegrationRuntime(t)
+
+ createResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ nil,
+ )
+ if createResp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(createResp.Body)
+ _ = createResp.Body.Close()
+ t.Fatalf("create resource status = %d, want %d; body=%s", createResp.StatusCode, http.StatusCreated, string(body))
+ }
+ var created contract.ResourceResponse
+ decodeHTTPJSON(t, createResp, &created)
+ if created.Record.Version != 1 {
+ t.Fatalf("created version = %d, want 1", created.Record.Version)
+ }
+ if strings.TrimSpace(string(created.Record.Spec)) != `{"enabled":true}` {
+ t.Fatalf("created spec = %s, want enabled true", string(created.Record.Spec))
+ }
+
+ updateResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(fmt.Sprintf(`{"scope":{"kind":"global"},"expected_version":%d,"spec":{"enabled":false}}`, created.Record.Version)),
+ nil,
+ )
+ if updateResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(updateResp.Body)
+ _ = updateResp.Body.Close()
+ t.Fatalf("update resource status = %d, want %d; body=%s", updateResp.StatusCode, http.StatusOK, string(body))
+ }
+ var updated contract.ResourceResponse
+ decodeHTTPJSON(t, updateResp, &updated)
+ if updated.Record.Version != 2 {
+ t.Fatalf("updated version = %d, want 2", updated.Record.Version)
+ }
+ if strings.TrimSpace(string(updated.Record.Spec)) != `{"enabled":false}` {
+ t.Fatalf("updated spec = %s, want enabled false", string(updated.Record.Spec))
+ }
+
+ getResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/resources/bundle.activation/demo", nil, nil)
+ if getResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(getResp.Body)
+ _ = getResp.Body.Close()
+ t.Fatalf("get resource status = %d, want %d; body=%s", getResp.StatusCode, http.StatusOK, string(body))
+ }
+ var fetched contract.ResourceResponse
+ decodeHTTPJSON(t, getResp, &fetched)
+ if fetched.Record.Version != updated.Record.Version {
+ t.Fatalf("fetched version = %d, want %d", fetched.Record.Version, updated.Record.Version)
+ }
+
+ listResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodGet,
+ "http://unix/api/resources/bundle.activation?scope_kind=global",
+ nil,
+ nil,
+ )
+ if listResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(listResp.Body)
+ _ = listResp.Body.Close()
+ t.Fatalf("list resources status = %d, want %d; body=%s", listResp.StatusCode, http.StatusOK, string(body))
+ }
+ var listed contract.ResourcesResponse
+ decodeHTTPJSON(t, listResp, &listed)
+ if len(listed.Records) != 1 || listed.Records[0].ID != "demo" || listed.Records[0].Version != updated.Record.Version {
+ t.Fatalf("listed records = %#v, want updated demo record", listed.Records)
+ }
+}
+
+func TestUDSToolResourceCRUDRoundTripTriggersProjection(t *testing.T) {
+ runtime := newIntegrationRuntime(t)
+
+ createResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/tool/lookup",
+ []byte(`{
+ "scope":{"kind":"global"},
+ "spec":{
+ "name":" lookup ",
+ "description":" search workspace ",
+ "input_schema":{"type":"object"},
+ "read_only":true,
+ "source":"dynamic"
+ }
+ }`),
+ nil,
+ )
+ if createResp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(createResp.Body)
+ _ = createResp.Body.Close()
+ t.Fatalf("create tool resource status = %d, want %d; body=%s", createResp.StatusCode, http.StatusCreated, string(body))
+ }
+ var created contract.ResourceResponse
+ decodeHTTPJSON(t, createResp, &created)
+ if got, want := strings.TrimSpace(string(created.Record.Spec)), `{"name":"lookup","description":"search workspace","input_schema":{"type":"object"},"read_only":true,"source":"dynamic"}`; got != want {
+ t.Fatalf("created tool spec = %s, want %s", got, want)
+ }
+
+ waitForProjectedToolRevision(t, runtime, 1)
+ revision, records := runtime.toolCatalog.snapshot()
+ if revision != 1 || len(records) != 1 || records[0].Spec.Name != "lookup" {
+ t.Fatalf("tool projection after create = revision:%d records:%#v, want lookup@1", revision, records)
+ }
+
+ updateResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/tool/lookup",
+ []byte(fmt.Sprintf(`{
+ "scope":{"kind":"global"},
+ "expected_version":%d,
+ "spec":{
+ "name":"lookup",
+ "description":"search workspace v2",
+ "input_schema":{"type":"object"},
+ "read_only":true,
+ "source":"dynamic"
+ }
+ }`, created.Record.Version)),
+ nil,
+ )
+ if updateResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(updateResp.Body)
+ _ = updateResp.Body.Close()
+ t.Fatalf("update tool resource status = %d, want %d; body=%s", updateResp.StatusCode, http.StatusOK, string(body))
+ }
+ var updated contract.ResourceResponse
+ decodeHTTPJSON(t, updateResp, &updated)
+ waitForProjectedToolRevision(t, runtime, 2)
+
+ _, records = runtime.toolCatalog.snapshot()
+ if got, want := records[0].Spec.Description, "search workspace v2"; got != want {
+ t.Fatalf("projected tool description = %q, want %q", got, want)
+ }
+}
+
+func TestUDSDeleteResourceRejectsStaleVersionAndRequiresCurrentVersion(t *testing.T) {
+ runtime := newIntegrationRuntime(t)
+
+ createResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(`{"scope":{"kind":"global"},"spec":{"enabled":true}}`),
+ nil,
+ )
+ if createResp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(createResp.Body)
+ _ = createResp.Body.Close()
+ t.Fatalf("create resource status = %d, want %d; body=%s", createResp.StatusCode, http.StatusCreated, string(body))
+ }
+ var created contract.ResourceResponse
+ decodeHTTPJSON(t, createResp, &created)
+
+ updateResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(fmt.Sprintf(`{"scope":{"kind":"global"},"expected_version":%d,"spec":{"enabled":false}}`, created.Record.Version)),
+ nil,
+ )
+ if updateResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(updateResp.Body)
+ _ = updateResp.Body.Close()
+ t.Fatalf("update resource status = %d, want %d; body=%s", updateResp.StatusCode, http.StatusOK, string(body))
+ }
+ var updated contract.ResourceResponse
+ decodeHTTPJSON(t, updateResp, &updated)
+
+ staleDelete := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodDelete,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(fmt.Sprintf(`{"expected_version":%d}`, created.Record.Version)),
+ nil,
+ )
+ if staleDelete.StatusCode != http.StatusConflict {
+ body, _ := io.ReadAll(staleDelete.Body)
+ _ = staleDelete.Body.Close()
+ t.Fatalf("stale delete status = %d, want %d; body=%s", staleDelete.StatusCode, http.StatusConflict, string(body))
+ }
+ _ = staleDelete.Body.Close()
+
+ deleteResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodDelete,
+ "http://unix/api/resources/bundle.activation/demo",
+ []byte(fmt.Sprintf(`{"expected_version":%d}`, updated.Record.Version)),
+ nil,
+ )
+ if deleteResp.StatusCode != http.StatusNoContent {
+ body, _ := io.ReadAll(deleteResp.Body)
+ _ = deleteResp.Body.Close()
+ t.Fatalf("delete resource status = %d, want %d; body=%s", deleteResp.StatusCode, http.StatusNoContent, string(body))
+ }
+ _ = deleteResp.Body.Close()
+
+ getResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/resources/bundle.activation/demo", nil, nil)
+ if getResp.StatusCode != http.StatusNotFound {
+ body, _ := io.ReadAll(getResp.Body)
+ _ = getResp.Body.Close()
+ t.Fatalf("get deleted resource status = %d, want %d; body=%s", getResp.StatusCode, http.StatusNotFound, string(body))
+ }
+}
+
func TestUDSAutomationJobsRoundTrip(t *testing.T) {
runtime := newIntegrationRuntime(t)
@@ -210,6 +435,160 @@ func TestUDSAutomationJobsRoundTrip(t *testing.T) {
_ = deleteResp.Body.Close()
}
+func TestUDSAutomationResourceWritesProjectJobsAndTriggers(t *testing.T) {
+ runtime := newIntegrationRuntime(t)
+
+ jobID := "resource-job"
+ createJobResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/automation.job/"+jobID,
+ []byte(`{"scope":{"kind":"global"},"spec":{"scope":"global","name":"resource-job","agent_name":"coder","prompt":"review from resource","schedule":{"mode":"every","interval":"1h"},"enabled":true,"source":"dynamic"}}`),
+ nil,
+ )
+ if createJobResp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(createJobResp.Body)
+ _ = createJobResp.Body.Close()
+ t.Fatalf("create automation.job resource status = %d, want %d; body=%s", createJobResp.StatusCode, http.StatusCreated, string(body))
+ }
+ var createdJobResource contract.ResourceResponse
+ decodeHTTPJSON(t, createJobResp, &createdJobResource)
+ projectedJob := waitForAutomationJobPrompt(t, runtime, jobID, "review from resource")
+ if projectedJob.Source != automationpkg.JobSourceDynamic {
+ t.Fatalf("projected job source = %q, want %q", projectedJob.Source, automationpkg.JobSourceDynamic)
+ }
+
+ triggerRunResp := mustUnixRequest(t, runtime.client, http.MethodPost, "http://unix/api/automation/jobs/"+jobID+"/trigger", nil, nil)
+ if triggerRunResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(triggerRunResp.Body)
+ _ = triggerRunResp.Body.Close()
+ t.Fatalf("trigger resource job status = %d, want %d; body=%s", triggerRunResp.StatusCode, http.StatusOK, string(body))
+ }
+ var jobRun contract.RunResponse
+ decodeHTTPJSON(t, triggerRunResp, &jobRun)
+ if jobRun.Run.JobID != jobID {
+ t.Fatalf("resource job run = %#v, want job_id %q", jobRun.Run, jobID)
+ }
+
+ updateJobResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/automation.job/"+jobID,
+ []byte(fmt.Sprintf(
+ `{"scope":{"kind":"global"},"expected_version":%d,"spec":{"scope":"global","name":"resource-job","agent_name":"coder","prompt":"review after resource update","schedule":{"mode":"every","interval":"1h"},"enabled":true,"source":"dynamic"}}`,
+ createdJobResource.Record.Version,
+ )),
+ nil,
+ )
+ if updateJobResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(updateJobResp.Body)
+ _ = updateJobResp.Body.Close()
+ t.Fatalf("update automation.job resource status = %d, want %d; body=%s", updateJobResp.StatusCode, http.StatusOK, string(body))
+ }
+ var updatedJobResource contract.ResourceResponse
+ decodeHTTPJSON(t, updateJobResp, &updatedJobResource)
+ waitForAutomationJobPrompt(t, runtime, jobID, "review after resource update")
+
+ runResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/automation/runs/"+jobRun.Run.ID, nil, nil)
+ if runResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(runResp.Body)
+ _ = runResp.Body.Close()
+ t.Fatalf("get resource job run status = %d, want %d; body=%s", runResp.StatusCode, http.StatusOK, string(body))
+ }
+ var fetchedRun contract.RunResponse
+ decodeHTTPJSON(t, runResp, &fetchedRun)
+ if fetchedRun.Run.ID != jobRun.Run.ID || fetchedRun.Run.JobID != jobID {
+ t.Fatalf("fetched resource job run = %#v, want run %q for job %q", fetchedRun.Run, jobRun.Run.ID, jobID)
+ }
+
+ triggerID := "resource-trigger"
+ createTriggerResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/automation.trigger/"+triggerID,
+ []byte(`{"scope":{"kind":"global"},"spec":{"scope":"global","name":"resource-trigger","agent_name":"coder","prompt":"inspect {{ index .Data \"session_id\" }}","event":"session.stopped","enabled":true,"source":"dynamic"}}`),
+ nil,
+ )
+ if createTriggerResp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(createTriggerResp.Body)
+ _ = createTriggerResp.Body.Close()
+ t.Fatalf("create automation.trigger resource status = %d, want %d; body=%s", createTriggerResp.StatusCode, http.StatusCreated, string(body))
+ }
+ var createdTriggerResource contract.ResourceResponse
+ decodeHTTPJSON(t, createTriggerResp, &createdTriggerResource)
+ projectedTrigger := waitForAutomationTriggerPrompt(t, runtime, triggerID, `inspect {{ index .Data "session_id" }}`)
+ if projectedTrigger.Source != automationpkg.JobSourceDynamic {
+ t.Fatalf("projected trigger source = %q, want %q", projectedTrigger.Source, automationpkg.JobSourceDynamic)
+ }
+
+ updateTriggerResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodPut,
+ "http://unix/api/resources/automation.trigger/"+triggerID,
+ []byte(fmt.Sprintf(
+ `{"scope":{"kind":"global"},"expected_version":%d,"spec":{"scope":"global","name":"resource-trigger","agent_name":"coder","prompt":"inspect resource {{ index .Data \"session_id\" }}","event":"session.stopped","enabled":true,"source":"dynamic"}}`,
+ createdTriggerResource.Record.Version,
+ )),
+ nil,
+ )
+ if updateTriggerResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(updateTriggerResp.Body)
+ _ = updateTriggerResp.Body.Close()
+ t.Fatalf("update automation.trigger resource status = %d, want %d; body=%s", updateTriggerResp.StatusCode, http.StatusOK, string(body))
+ }
+ var updatedTriggerResource contract.ResourceResponse
+ decodeHTTPJSON(t, updateTriggerResp, &updatedTriggerResource)
+ waitForAutomationTriggerPrompt(t, runtime, triggerID, `inspect resource {{ index .Data "session_id" }}`)
+
+ deleteTriggerResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodDelete,
+ "http://unix/api/resources/automation.trigger/"+triggerID,
+ []byte(fmt.Sprintf(`{"expected_version":%d}`, updatedTriggerResource.Record.Version)),
+ nil,
+ )
+ if deleteTriggerResp.StatusCode != http.StatusNoContent {
+ body, _ := io.ReadAll(deleteTriggerResp.Body)
+ _ = deleteTriggerResp.Body.Close()
+ t.Fatalf("delete automation.trigger resource status = %d, want %d; body=%s", deleteTriggerResp.StatusCode, http.StatusNoContent, string(body))
+ }
+ _ = deleteTriggerResp.Body.Close()
+ waitForAutomationTriggerMissing(t, runtime, triggerID)
+
+ deleteJobResp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodDelete,
+ "http://unix/api/resources/automation.job/"+jobID,
+ []byte(fmt.Sprintf(`{"expected_version":%d}`, updatedJobResource.Record.Version)),
+ nil,
+ )
+ if deleteJobResp.StatusCode != http.StatusNoContent {
+ body, _ := io.ReadAll(deleteJobResp.Body)
+ _ = deleteJobResp.Body.Close()
+ t.Fatalf("delete automation.job resource status = %d, want %d; body=%s", deleteJobResp.StatusCode, http.StatusNoContent, string(body))
+ }
+ _ = deleteJobResp.Body.Close()
+ waitForAutomationJobMissing(t, runtime, jobID)
+
+ runAfterDeleteResp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/automation/runs/"+jobRun.Run.ID, nil, nil)
+ if runAfterDeleteResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(runAfterDeleteResp.Body)
+ _ = runAfterDeleteResp.Body.Close()
+ t.Fatalf("get run after resource delete status = %d, want %d; body=%s", runAfterDeleteResp.StatusCode, http.StatusOK, string(body))
+ }
+ var runAfterDelete contract.RunResponse
+ decodeHTTPJSON(t, runAfterDeleteResp, &runAfterDelete)
+ if runAfterDelete.Run.ID != jobRun.Run.ID || runAfterDelete.Run.JobID != jobID {
+ t.Fatalf("run after resource delete = %#v, want run %q for job %q", runAfterDelete.Run, jobRun.Run.ID, jobID)
+ }
+}
+
func TestUDSAutomationTriggerRunsAndOmitsWebhookRoutes(t *testing.T) {
runtime := newIntegrationRuntime(t)
@@ -730,17 +1109,288 @@ func TestUDSTaskRunLifecycleRoutesRoundTrip(t *testing.T) {
}
type integrationRuntime struct {
- client *http.Client
- server *Server
- manager *session.Manager
- tasks *taskpkg.Service
- observer *observe.Observer
- registry *globaldb.GlobalDB
- bridges *integrationBridgeService
- memory *memory.Store
- dream *integrationDreamTrigger
- socket string
- workspace string
+ client *http.Client
+ server *Server
+ manager *session.Manager
+ tasks *taskpkg.Service
+ observer *observe.Observer
+ registry *globaldb.GlobalDB
+ bridges *integrationBridgeService
+ memory *memory.Store
+ dream *integrationDreamTrigger
+ resourceDriver resources.ReconcileDriver
+ toolCatalog *integrationToolCatalog
+ socket string
+ workspace string
+}
+
+type integrationToolCatalog struct {
+ mu sync.Mutex
+ revision int64
+ records []resources.Record[toolspkg.Tool]
+}
+
+type integrationToolPlan struct {
+ revision int64
+ operations int
+ records []resources.Record[toolspkg.Tool]
+}
+
+func (p *integrationToolPlan) Kind() resources.ResourceKind { return toolspkg.ToolResourceKind }
+func (p *integrationToolPlan) Revision() int64 { return p.revision }
+func (p *integrationToolPlan) OperationCount() int { return p.operations }
+
+type integrationToolProjector struct {
+ catalog *integrationToolCatalog
+}
+
+func (p *integrationToolProjector) Kind() resources.ResourceKind {
+ return toolspkg.ToolResourceKind
+}
+
+func (p *integrationToolProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *integrationToolProjector) Build(
+ _ context.Context,
+ records []resources.Record[toolspkg.Tool],
+) (resources.ProjectionPlan, error) {
+ var revision int64
+ cloned := make([]resources.Record[toolspkg.Tool], 0, len(records))
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ next := record
+ next.Spec = cloneIntegrationTool(record.Spec)
+ cloned = append(cloned, next)
+ }
+ return &integrationToolPlan{
+ revision: revision,
+ operations: len(records),
+ records: cloned,
+ }, nil
+}
+
+func (p *integrationToolProjector) Apply(_ context.Context, plan resources.ProjectionPlan) error {
+ typed, ok := plan.(*integrationToolPlan)
+ if !ok {
+ return fmt.Errorf("integration tool plan type = %T, want *integrationToolPlan", plan)
+ }
+ p.catalog.replace(typed.revision, typed.records)
+ return nil
+}
+
+type integrationAutomationJobProjector struct {
+ manager *automationpkg.Manager
+}
+
+func (p *integrationAutomationJobProjector) Kind() resources.ResourceKind {
+ return automationpkg.JobResourceKind
+}
+
+func (p *integrationAutomationJobProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *integrationAutomationJobProjector) Build(
+ ctx context.Context,
+ records []resources.Record[automationpkg.Job],
+) (resources.ProjectionPlan, error) {
+ return p.manager.BuildJobResourceState(ctx, records)
+}
+
+func (p *integrationAutomationJobProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ return p.manager.ApplyJobResourceState(ctx, plan)
+}
+
+type integrationAutomationTriggerProjector struct {
+ manager *automationpkg.Manager
+}
+
+func (p *integrationAutomationTriggerProjector) Kind() resources.ResourceKind {
+ return automationpkg.TriggerResourceKind
+}
+
+func (p *integrationAutomationTriggerProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *integrationAutomationTriggerProjector) Build(
+ ctx context.Context,
+ records []resources.Record[automationpkg.Trigger],
+) (resources.ProjectionPlan, error) {
+ return p.manager.BuildTriggerResourceState(ctx, records)
+}
+
+func (p *integrationAutomationTriggerProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ return p.manager.ApplyTriggerResourceState(ctx, plan)
+}
+
+func (c *integrationToolCatalog) replace(revision int64, records []resources.Record[toolspkg.Tool]) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.revision = revision
+ c.records = cloneIntegrationToolRecords(records)
+}
+
+func (c *integrationToolCatalog) snapshot() (int64, []resources.Record[toolspkg.Tool]) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.revision, cloneIntegrationToolRecords(c.records)
+}
+
+func cloneIntegrationToolRecords(records []resources.Record[toolspkg.Tool]) []resources.Record[toolspkg.Tool] {
+ if len(records) == 0 {
+ return nil
+ }
+ cloned := make([]resources.Record[toolspkg.Tool], 0, len(records))
+ for _, record := range records {
+ next := record
+ next.Spec = cloneIntegrationTool(record.Spec)
+ cloned = append(cloned, next)
+ }
+ return cloned
+}
+
+func cloneIntegrationTool(spec toolspkg.Tool) toolspkg.Tool {
+ cloned := spec
+ if len(spec.InputSchema) > 0 {
+ cloned.InputSchema = append([]byte(nil), spec.InputSchema...)
+ }
+ return cloned
+}
+
+func waitForAutomationJobPrompt(
+ t *testing.T,
+ runtime integrationRuntime,
+ jobID string,
+ wantPrompt string,
+) contract.JobPayload {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ var last contract.JobResponse
+ var lastStatus int
+ for time.Now().Before(deadline) {
+ resp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/automation/jobs/"+jobID, nil, nil)
+ lastStatus = resp.StatusCode
+ if resp.StatusCode == http.StatusOK {
+ last = contract.JobResponse{}
+ decodeHTTPJSON(t, resp, &last)
+ if last.Job.Prompt == wantPrompt {
+ return last.Job
+ }
+ } else {
+ _ = resp.Body.Close()
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ t.Fatalf("timed out waiting for automation job %q prompt %q (status=%d, last=%#v)", jobID, wantPrompt, lastStatus, last.Job)
+ return contract.JobPayload{}
+}
+
+func waitForAutomationJobMissing(t *testing.T, runtime integrationRuntime, jobID string) {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ var lastStatus int
+ for time.Now().Before(deadline) {
+ resp := mustUnixRequest(t, runtime.client, http.MethodGet, "http://unix/api/automation/jobs/"+jobID, nil, nil)
+ lastStatus = resp.StatusCode
+ _ = resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ t.Fatalf("timed out waiting for automation job %q to be deleted (last status=%d)", jobID, lastStatus)
+}
+
+func waitForAutomationTriggerPrompt(
+ t *testing.T,
+ runtime integrationRuntime,
+ triggerID string,
+ wantPrompt string,
+) contract.TriggerPayload {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ var last contract.TriggerResponse
+ var lastStatus int
+ for time.Now().Before(deadline) {
+ resp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodGet,
+ "http://unix/api/automation/triggers/"+triggerID,
+ nil,
+ nil,
+ )
+ lastStatus = resp.StatusCode
+ if resp.StatusCode == http.StatusOK {
+ last = contract.TriggerResponse{}
+ decodeHTTPJSON(t, resp, &last)
+ if last.Trigger.Prompt == wantPrompt {
+ return last.Trigger
+ }
+ } else {
+ _ = resp.Body.Close()
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ t.Fatalf(
+ "timed out waiting for automation trigger %q prompt %q (status=%d, last=%#v)",
+ triggerID,
+ wantPrompt,
+ lastStatus,
+ last.Trigger,
+ )
+ return contract.TriggerPayload{}
+}
+
+func waitForAutomationTriggerMissing(t *testing.T, runtime integrationRuntime, triggerID string) {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ var lastStatus int
+ for time.Now().Before(deadline) {
+ resp := mustUnixRequest(
+ t,
+ runtime.client,
+ http.MethodGet,
+ "http://unix/api/automation/triggers/"+triggerID,
+ nil,
+ nil,
+ )
+ lastStatus = resp.StatusCode
+ _ = resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ t.Fatalf("timed out waiting for automation trigger %q to be deleted (last status=%d)", triggerID, lastStatus)
+}
+
+func waitForProjectedToolRevision(t *testing.T, runtime integrationRuntime, want int64) {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ revision, _ := runtime.toolCatalog.snapshot()
+ if revision >= want {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ revision, records := runtime.toolCatalog.snapshot()
+ t.Fatalf("timed out waiting for projected tool revision %d (got %d, records=%#v)", want, revision, records)
}
type integrationTaskSessionExecutor struct {
@@ -1021,6 +1671,61 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
t.Fatalf("registry.Close() error = %v", err)
}
})
+ resourceKernel, err := resources.NewKernel(registry.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ resourceActor := resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "uds-integration",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "uds-integration",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+ resourceCodecs := resources.NewCodecRegistry()
+ toolCodec, err := toolspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("toolspkg.NewResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(resourceCodecs, toolCodec); err != nil {
+ t.Fatalf("resources.RegisterCodec(tool) error = %v", err)
+ }
+ jobCodec, err := automationpkg.NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("automation.NewJobResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(resourceCodecs, jobCodec); err != nil {
+ t.Fatalf("resources.RegisterCodec(automation.job) error = %v", err)
+ }
+ jobResourceStore, err := resources.NewStore(resourceKernel, jobCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(automation.job) error = %v", err)
+ }
+ triggerCodec, err := automationpkg.NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("automation.NewTriggerResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(resourceCodecs, triggerCodec); err != nil {
+ t.Fatalf("resources.RegisterCodec(automation.trigger) error = %v", err)
+ }
+ triggerResourceStore, err := resources.NewStore(resourceKernel, triggerCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(automation.trigger) error = %v", err)
+ }
+ var resourceDriver resources.ReconcileDriver
+ resourceTrigger := func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ switch kind.Normalize() {
+ case toolspkg.ToolResourceKind, automationpkg.JobResourceKind, automationpkg.TriggerResourceKind:
+ default:
+ return nil
+ }
+ if resourceDriver == nil {
+ return nil
+ }
+ return resourceDriver.Trigger(ctx, kind, reason)
+ }
fanout := &integrationNotifierFanout{}
resolver, err := workspacepkg.NewResolver(
@@ -1032,12 +1737,17 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
if err != nil {
t.Fatalf("workspace.NewResolver() error = %v", err)
}
+ environmentRegistry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
manager, err := session.NewManager(
session.WithHomePaths(homePaths),
session.WithWorkspaceResolver(resolver),
session.WithLogger(discardLogger()),
session.WithDriver(newIntegrationDriver()),
session.WithNotifier(fanout),
+ session.WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
@@ -1073,6 +1783,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
automationpkg.WithConfig(cfg.Automation),
automationpkg.WithLogger(discardLogger()),
automationpkg.WithGlobalWorkspacePath(homePaths.HomeDir),
+ automationpkg.WithResourceDefinitions(jobResourceStore, triggerResourceStore, resourceActor, resourceTrigger),
)
if err != nil {
t.Fatalf("automation.New() error = %v", err)
@@ -1098,6 +1809,48 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
t.Fatalf("task.NewManager() error = %v", err)
}
+ toolCatalog := &integrationToolCatalog{}
+ toolRegistration, err := resources.NewTypedProjectorRegistration(toolCodec, &integrationToolProjector{catalog: toolCatalog})
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(tool) error = %v", err)
+ }
+ jobRegistration, err := resources.NewTypedProjectorRegistration(
+ jobCodec,
+ &integrationAutomationJobProjector{manager: automationManager},
+ )
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(automation.job) error = %v", err)
+ }
+ triggerRegistration, err := resources.NewTypedProjectorRegistration(
+ triggerCodec,
+ &integrationAutomationTriggerProjector{manager: automationManager},
+ )
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(automation.trigger) error = %v", err)
+ }
+ resourceDriver, err = resources.NewReconcileDriver(
+ resourceKernel,
+ resourceActor,
+ []resources.ProjectorRegistration{toolRegistration, jobRegistration, triggerRegistration},
+ resources.WithReconcileLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := resourceDriver.Close(context.Background()); err != nil {
+ t.Fatalf("resourceDriver.Close() error = %v", err)
+ }
+ })
+ resourceService, err := core.NewOperatorResourceService(&core.ResourceServiceConfig{
+ RawStore: resourceKernel,
+ CodecRegistry: resourceCodecs,
+ Trigger: resourceTrigger,
+ })
+ if err != nil {
+ t.Fatalf("core.NewOperatorResourceService() error = %v", err)
+ }
+
server, err := New(
WithHomePaths(homePaths),
WithConfig(&cfg),
@@ -1106,6 +1859,7 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
WithSessionManager(manager),
WithTaskService(taskManager),
WithObserver(observer),
+ WithResourceService(resourceService),
WithAutomation(automationManager),
WithBridgeService(bridgeService),
WithWorkspaceResolver(resolver),
@@ -1128,17 +1882,19 @@ func newIntegrationRuntime(t *testing.T) integrationRuntime {
})
return integrationRuntime{
- client: newUnixClient(t, socketPath),
- server: server,
- manager: manager,
- tasks: taskManager,
- observer: observer,
- registry: registry,
- bridges: bridgeService,
- memory: memoryStore,
- dream: dreamTrigger,
- socket: socketPath,
- workspace: workspace,
+ client: newUnixClient(t, socketPath),
+ server: server,
+ manager: manager,
+ tasks: taskManager,
+ observer: observer,
+ registry: registry,
+ bridges: bridgeService,
+ memory: memoryStore,
+ dream: dreamTrigger,
+ resourceDriver: resourceDriver,
+ toolCatalog: toolCatalog,
+ socket: socketPath,
+ workspace: workspace,
}
}
diff --git a/internal/automation/manager.go b/internal/automation/manager.go
index b5a9c0e62..4e5d5af0c 100644
--- a/internal/automation/manager.go
+++ b/internal/automation/manager.go
@@ -17,6 +17,7 @@ import (
aghconfig "github.com/pedronauck/agh/internal/config"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
taskpkg "github.com/pedronauck/agh/internal/task"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
@@ -139,6 +140,10 @@ type managerOptions struct {
dispatcherOptions []DispatcherOption
schedulerOptions []SchedulerOption
triggerOptions []TriggerEngineOption
+ jobResources resources.Store[Job]
+ triggerResources resources.Store[Trigger]
+ resourceActor resources.MutationActor
+ resourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
now func() time.Time
}
@@ -194,13 +199,27 @@ func finalizeManagerOptions(options *managerOptions) error {
if options.webhookSecrets == nil {
options.webhookSecrets = storeWebhookSecretResolver{store: options.store}
}
+ if options.jobResources != nil || options.triggerResources != nil {
+ if options.jobResources == nil {
+ return errors.New("automation: job resource store is required when resource definitions are enabled")
+ }
+ if options.triggerResources == nil {
+ return errors.New("automation: trigger resource store is required when resource definitions are enabled")
+ }
+ if options.resourceActor.Kind == "" {
+ options.resourceActor = defaultAutomationResourceActor()
+ }
+ if err := options.resourceActor.Kind.Normalize().Validate("automation.resource_actor.kind"); err != nil {
+ return err
+ }
+ }
if strings.TrimSpace(options.globalWorkspacePath) == "" {
return errors.New("automation: global workspace path is required")
}
return nil
}
-func managerDispatcherOptions(options managerOptions) []DispatcherOption {
+func managerDispatcherOptions(options *managerOptions) []DispatcherOption {
dispatcherOpts := []DispatcherOption{
WithDispatcherLogger(options.logger),
WithDispatcherGlobalWorkspacePath(options.globalWorkspacePath),
@@ -226,15 +245,23 @@ type Manager struct {
dispatcher *Dispatcher
schedulerOptions []SchedulerOption
triggerOptions []TriggerEngineOption
+ jobResources resources.Store[Job]
+ triggerResources resources.Store[Trigger]
+ resourceActor resources.MutationActor
+ resourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
now func() time.Time
- mu sync.RWMutex
- running bool
- runtimeCtx context.Context
- runtimeCancel context.CancelFunc
- scheduler *Scheduler
- triggers *TriggerEngine
- lastSync SyncStats
+ mu sync.RWMutex
+ running bool
+ runtimeCtx context.Context
+ runtimeCancel context.CancelFunc
+ scheduler *Scheduler
+ triggers *TriggerEngine
+ lastSync SyncStats
+ projectedJobs map[string]Job
+ projectedTriggers map[string]Trigger
+ jobRevision int64
+ triggerRevision int64
taskActorMu sync.RWMutex
sessionTaskActors map[string]taskpkg.ActorContext
@@ -342,6 +369,22 @@ func WithManagerNow(now func() time.Time) Option {
}
}
+// WithResourceDefinitions switches desired-state automation definitions to the
+// shared resource runtime while keeping operational run state on Store.
+func WithResourceDefinitions(
+ jobStore resources.Store[Job],
+ triggerStore resources.Store[Trigger],
+ actor resources.MutationActor,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+) Option {
+ return func(opts *managerOptions) {
+ opts.jobResources = jobStore
+ opts.triggerResources = triggerStore
+ opts.resourceActor = actor
+ opts.resourceTrigger = trigger
+ }
+}
+
// New constructs the composed automation manager.
func New(opts ...Option) (*Manager, error) {
options := defaultManagerOptions()
@@ -350,7 +393,7 @@ func New(opts ...Option) (*Manager, error) {
return nil, err
}
- dispatcherOpts := managerDispatcherOptions(options)
+ dispatcherOpts := managerDispatcherOptions(&options)
dispatcher, err := NewDispatcher(options.sessions, options.store, dispatcherOpts...)
if err != nil {
return nil, fmt.Errorf("automation: construct dispatcher: %w", err)
@@ -368,7 +411,13 @@ func New(opts ...Option) (*Manager, error) {
dispatcher: dispatcher,
schedulerOptions: append([]SchedulerOption(nil), options.schedulerOptions...),
triggerOptions: append([]TriggerEngineOption(nil), options.triggerOptions...),
+ jobResources: options.jobResources,
+ triggerResources: options.triggerResources,
+ resourceActor: options.resourceActor,
+ resourceTrigger: options.resourceTrigger,
now: options.now,
+ projectedJobs: make(map[string]Job),
+ projectedTriggers: make(map[string]Trigger),
sessionTaskActors: make(map[string]taskpkg.ActorContext),
}
if manager.tasks != nil {
@@ -397,13 +446,9 @@ func (m *Manager) Start(ctx context.Context) error {
return fmt.Errorf("automation: sync config definitions: %w", err)
}
- jobs, err := m.loadEffectiveJobs(ctx, JobListQuery{})
+ jobs, triggers, err := m.loadStartupDefinitionsLocked(ctx)
if err != nil {
- return fmt.Errorf("automation: load effective jobs: %w", err)
- }
- triggers, err := m.loadEffectiveTriggers(ctx, TriggerListQuery{})
- if err != nil {
- return fmt.Errorf("automation: load effective triggers: %w", err)
+ return err
}
runtimeCtx, runtimeCancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -457,6 +502,43 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
+func (m *Manager) loadStartupDefinitionsLocked(ctx context.Context) ([]Job, []Trigger, error) {
+ if m.resourceDefinitionsEnabled() {
+ projectedJobs, jobRevision, err := m.loadProjectedJobDefinitionsFromStore(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load projected job resources: %w", err)
+ }
+ projectedTriggers, triggerRevision, err := m.loadProjectedTriggerDefinitionsFromStore(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load projected trigger resources: %w", err)
+ }
+ m.projectedJobs = jobMapFromSlice(projectedJobs)
+ m.jobRevision = jobRevision
+ m.projectedTriggers = triggerMapFromSlice(projectedTriggers)
+ m.triggerRevision = triggerRevision
+
+ jobs, err := m.applyJobQueryAndOverlays(ctx, m.projectedJobDefinitionsLocked(), JobListQuery{})
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load effective jobs: %w", err)
+ }
+ triggers, err := m.applyTriggerQueryAndOverlays(ctx, m.projectedTriggerDefinitionsLocked(), TriggerListQuery{})
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load effective triggers: %w", err)
+ }
+ return jobs, triggers, nil
+ }
+
+ jobs, err := m.loadEffectiveJobs(ctx, JobListQuery{})
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load effective jobs: %w", err)
+ }
+ triggers, err := m.loadEffectiveTriggers(ctx, TriggerListQuery{})
+ if err != nil {
+ return nil, nil, fmt.Errorf("automation: load effective triggers: %w", err)
+ }
+ return jobs, triggers, nil
+}
+
// Shutdown stops trigger ingestion, cancels in-flight work, and shuts down the
// runtime scheduler.
func (m *Manager) Shutdown(ctx context.Context) error {
@@ -527,6 +609,9 @@ func (m *Manager) CreateJob(ctx context.Context, job Job) (Job, error) {
if ctx == nil {
return Job{}, errors.New("automation: create job context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.createJobResource(ctx, job)
+ }
next := cloneJob(job)
if next.Source == "" {
@@ -557,6 +642,9 @@ func (m *Manager) UpdateJob(ctx context.Context, job Job) (Job, error) {
if ctx == nil {
return Job{}, errors.New("automation: update job context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.updateJobResource(ctx, job)
+ }
currentStored, err := m.store.GetJob(ctx, strings.TrimSpace(job.ID))
if err != nil {
@@ -610,6 +698,9 @@ func (m *Manager) DeleteJob(ctx context.Context, id string) error {
if ctx == nil {
return errors.New("automation: delete job context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.deleteJobResource(ctx, id)
+ }
currentStored, err := m.store.GetJob(ctx, strings.TrimSpace(id))
if err != nil {
@@ -697,6 +788,9 @@ func (m *Manager) CreateTrigger(ctx context.Context, trigger Trigger, webhookSec
if ctx == nil {
return Trigger{}, errors.New("automation: create trigger context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.createTriggerResource(ctx, trigger, webhookSecret)
+ }
next := cloneTrigger(trigger)
if next.Source == "" {
@@ -734,6 +828,9 @@ func (m *Manager) UpdateTrigger(ctx context.Context, trigger Trigger, webhookSec
if ctx == nil {
return Trigger{}, errors.New("automation: update trigger context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.updateTriggerResource(ctx, trigger, webhookSecret)
+ }
currentStored, err := m.store.GetTrigger(ctx, strings.TrimSpace(trigger.ID))
if err != nil {
@@ -810,6 +907,9 @@ func (m *Manager) DeleteTrigger(ctx context.Context, id string) error {
if ctx == nil {
return errors.New("automation: delete trigger context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.deleteTriggerResource(ctx, id)
+ }
currentStored, err := m.store.GetTrigger(ctx, strings.TrimSpace(id))
if err != nil {
@@ -919,6 +1019,9 @@ func (m *Manager) SetJobEnabled(ctx context.Context, id string, enabled bool) (J
if ctx == nil {
return Job{}, errors.New("automation: set job enabled context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.setJobResourceEnabled(ctx, id, enabled)
+ }
stored, err := m.store.GetJob(ctx, strings.TrimSpace(id))
if err != nil {
@@ -961,6 +1064,9 @@ func (m *Manager) SetTriggerEnabled(ctx context.Context, id string, enabled bool
if ctx == nil {
return Trigger{}, errors.New("automation: set trigger enabled context is required")
}
+ if m.resourceDefinitionsEnabled() {
+ return m.setTriggerResourceEnabled(ctx, id, enabled)
+ }
stored, err := m.store.GetTrigger(ctx, strings.TrimSpace(id))
if err != nil {
@@ -1110,6 +1216,11 @@ func (m *Manager) DeleteAutomationSessionTaskActor(sessionID string) {
}
func (m *Manager) loadEffectiveJobs(ctx context.Context, query JobListQuery) ([]Job, error) {
+ if m.resourceDefinitionsEnabled() {
+ jobs := m.projectedJobDefinitions()
+ return m.applyJobQueryAndOverlays(ctx, jobs, query)
+ }
+
jobs, err := m.store.ListJobs(ctx, query)
if err != nil {
return nil, err
@@ -1139,6 +1250,11 @@ func (m *Manager) loadEffectiveJobs(ctx context.Context, query JobListQuery) ([]
}
func (m *Manager) loadEffectiveTriggers(ctx context.Context, query TriggerListQuery) ([]Trigger, error) {
+ if m.resourceDefinitionsEnabled() {
+ triggers := m.projectedTriggerDefinitions()
+ return m.applyTriggerQueryAndOverlays(ctx, triggers, query)
+ }
+
triggers, err := m.store.ListTriggers(ctx, query)
if err != nil {
return nil, err
@@ -1168,6 +1284,14 @@ func (m *Manager) loadEffectiveTriggers(ctx context.Context, query TriggerListQu
}
func (m *Manager) effectiveJob(ctx context.Context, id string) (Job, error) {
+ if m.resourceDefinitionsEnabled() {
+ job, err := m.projectedJobDefinition(id)
+ if err != nil {
+ return Job{}, err
+ }
+ return m.effectiveJobFromStored(ctx, job)
+ }
+
job, err := m.store.GetJob(ctx, id)
if err != nil {
return Job{}, err
@@ -1194,6 +1318,14 @@ func (m *Manager) effectiveJobFromStored(ctx context.Context, job Job) (Job, err
}
func (m *Manager) effectiveTrigger(ctx context.Context, id string) (Trigger, error) {
+ if m.resourceDefinitionsEnabled() {
+ trigger, err := m.projectedTriggerDefinition(id)
+ if err != nil {
+ return Trigger{}, err
+ }
+ return m.effectiveTriggerFromStored(ctx, trigger)
+ }
+
trigger, err := m.store.GetTrigger(ctx, id)
if err != nil {
return Trigger{}, err
@@ -1220,9 +1352,26 @@ func (m *Manager) effectiveTriggerFromStored(ctx context.Context, trigger Trigge
}
func (m *Manager) buildRuntimes(ctx context.Context) (*Scheduler, *TriggerEngine, error) {
+ scheduler, err := m.buildSchedulerRuntime(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ triggerEngine, err := m.buildTriggerRuntime(ctx)
+ if err != nil {
+ return nil, nil, errors.Join(
+ err,
+ m.shutdownRuntimeComponent(ctx, "scheduler", scheduler),
+ )
+ }
+
+ return scheduler, triggerEngine, nil
+}
+
+func (m *Manager) buildSchedulerRuntime(_ context.Context) (*Scheduler, error) {
location, err := time.LoadLocation(strings.TrimSpace(m.config.Timezone))
if err != nil {
- return nil, nil, fmt.Errorf("automation: load manager timezone %q: %w", m.config.Timezone, err)
+ return nil, fmt.Errorf("automation: load manager timezone %q: %w", m.config.Timezone, err)
}
schedulerOpts := []SchedulerOption{
@@ -1232,9 +1381,12 @@ func (m *Manager) buildRuntimes(ctx context.Context) (*Scheduler, *TriggerEngine
schedulerOpts = append(schedulerOpts, m.schedulerOptions...)
scheduler, err := NewScheduler(m.dispatcher, schedulerOpts...)
if err != nil {
- return nil, nil, fmt.Errorf("automation: construct scheduler: %w", err)
+ return nil, fmt.Errorf("automation: construct scheduler: %w", err)
}
+ return scheduler, nil
+}
+func (m *Manager) buildTriggerRuntime(_ context.Context) (*TriggerEngine, error) {
triggerOpts := []TriggerEngineOption{
WithTriggerEngineLogger(m.logger),
WithTriggerEngineHookSessionResolver(m.sessions),
@@ -1242,13 +1394,9 @@ func (m *Manager) buildRuntimes(ctx context.Context) (*Scheduler, *TriggerEngine
triggerOpts = append(triggerOpts, m.triggerOptions...)
triggerEngine, err := NewTriggerEngine(m.dispatcher, triggerOpts...)
if err != nil {
- return nil, nil, errors.Join(
- fmt.Errorf("automation: construct trigger engine: %w", err),
- m.shutdownRuntimeComponent(ctx, "scheduler", scheduler),
- )
+ return nil, fmt.Errorf("automation: construct trigger engine: %w", err)
}
-
- return scheduler, triggerEngine, nil
+ return triggerEngine, nil
}
func (m *Manager) loadSchedulerRegistrations(jobs []Job, scheduler *Scheduler) error {
@@ -1354,6 +1502,10 @@ func (m *Manager) SyncManagedDefinitions(
secrets[strings.TrimSpace(id)] = strings.TrimSpace(secret)
}
+ if m.resourceDefinitionsEnabled() {
+ return m.syncManagedResourceDefinitions(ctx, source, jobs, triggers, secrets)
+ }
+
jobsSynced, jobsRemoved, err := m.syncJobsForSource(ctx, source, jobs)
if err != nil {
return SyncStats{}, err
diff --git a/internal/automation/resource.go b/internal/automation/resource.go
new file mode 100644
index 000000000..94fafa192
--- /dev/null
+++ b/internal/automation/resource.go
@@ -0,0 +1,178 @@
+package automation
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ // JobResourceKind is the canonical desired-state kind for scheduled automation jobs.
+ JobResourceKind resources.ResourceKind = "automation.job"
+ // TriggerResourceKind is the canonical desired-state kind for event-driven automation triggers.
+ TriggerResourceKind resources.ResourceKind = "automation.trigger"
+
+ automationResourceMaxBytes = 256 << 10
+)
+
+// NewJobResourceCodec builds the typed codec for automation.job records.
+func NewJobResourceCodec() (resources.KindCodec[Job], error) {
+ return resources.NewJSONCodec(JobResourceKind, automationResourceMaxBytes, validateJobResourceSpec)
+}
+
+// NewTriggerResourceCodec builds the typed codec for automation.trigger records.
+func NewTriggerResourceCodec() (resources.KindCodec[Trigger], error) {
+ return resources.NewJSONCodec(TriggerResourceKind, automationResourceMaxBytes, validateTriggerResourceSpec)
+}
+
+// ResourceScopeForAutomation converts automation scope fields into the shared resource scope.
+func ResourceScopeForAutomation(scope Scope, workspaceID string) resources.ResourceScope {
+ switch scope {
+ case AutomationScopeWorkspace:
+ return resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: strings.TrimSpace(workspaceID),
+ }
+ default:
+ return resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ }
+}
+
+func validateJobResourceSpec(_ context.Context, scope resources.ResourceScope, spec Job) (Job, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return Job{}, fmt.Errorf("automation: validate job resource scope: %w", err)
+ }
+
+ next := normalizeJobResourceSpec(spec)
+ if err := bindAutomationScope(&next.Scope, &next.WorkspaceID, normalizedScope, "job"); err != nil {
+ return Job{}, fmt.Errorf("automation: bind job resource scope: %w", err)
+ }
+ if err := next.Validate("job"); err != nil {
+ return Job{}, fmt.Errorf("automation: validate job resource spec: %w", err)
+ }
+ return next, nil
+}
+
+func validateTriggerResourceSpec(_ context.Context, scope resources.ResourceScope, spec Trigger) (Trigger, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return Trigger{}, fmt.Errorf("automation: validate trigger resource scope: %w", err)
+ }
+
+ next := normalizeTriggerResourceSpec(spec)
+ if err := bindAutomationScope(&next.Scope, &next.WorkspaceID, normalizedScope, "trigger"); err != nil {
+ return Trigger{}, fmt.Errorf("automation: bind trigger resource scope: %w", err)
+ }
+ if err := next.Validate("trigger"); err != nil {
+ return Trigger{}, fmt.Errorf("automation: validate trigger resource spec: %w", err)
+ }
+ return next, nil
+}
+
+func normalizeJobResourceSpec(spec Job) Job {
+ next := cloneJob(spec)
+ next.ID = strings.TrimSpace(next.ID)
+ next.Name = strings.TrimSpace(next.Name)
+ next.AgentName = strings.TrimSpace(next.AgentName)
+ next.WorkspaceID = strings.TrimSpace(next.WorkspaceID)
+ next.Prompt = strings.TrimSpace(next.Prompt)
+ if next.Source == "" {
+ next.Source = JobSourceDynamic
+ }
+ if next.Retry.Strategy == "" {
+ next.Retry = DefaultRetryConfig()
+ }
+ if next.FireLimit.Max == 0 || strings.TrimSpace(next.FireLimit.Window) == "" {
+ next.FireLimit = DefaultFireLimitConfig()
+ }
+ next.CreatedAt = next.CreatedAt.UTC()
+ next.UpdatedAt = next.UpdatedAt.UTC()
+ return next
+}
+
+func normalizeTriggerResourceSpec(spec Trigger) Trigger {
+ next := cloneTrigger(spec)
+ next.ID = strings.TrimSpace(next.ID)
+ next.Name = strings.TrimSpace(next.Name)
+ next.AgentName = strings.TrimSpace(next.AgentName)
+ next.WorkspaceID = strings.TrimSpace(next.WorkspaceID)
+ next.Prompt = strings.TrimSpace(next.Prompt)
+ next.Event = strings.TrimSpace(next.Event)
+ next.WebhookID = strings.TrimSpace(next.WebhookID)
+ next.EndpointSlug = strings.TrimSpace(next.EndpointSlug)
+ if next.Source == "" {
+ next.Source = JobSourceDynamic
+ }
+ if next.Retry.Strategy == "" {
+ next.Retry = DefaultRetryConfig()
+ }
+ if next.FireLimit.Max == 0 || strings.TrimSpace(next.FireLimit.Window) == "" {
+ next.FireLimit = DefaultFireLimitConfig()
+ }
+ next.CreatedAt = next.CreatedAt.UTC()
+ next.UpdatedAt = next.UpdatedAt.UTC()
+ return next
+}
+
+func bindAutomationScope(
+ domainScope *Scope,
+ workspaceID *string,
+ resourceScope resources.ResourceScope,
+ path string,
+) error {
+ switch resourceScope.Kind {
+ case resources.ResourceScopeKindGlobal:
+ if *domainScope == "" {
+ *domainScope = AutomationScopeGlobal
+ }
+ if *domainScope != AutomationScopeGlobal {
+ return fmt.Errorf(
+ "%w: %s.scope %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ path,
+ *domainScope,
+ resourceScope.Kind,
+ )
+ }
+ if strings.TrimSpace(*workspaceID) != "" {
+ return fmt.Errorf(
+ "%w: %s.workspace_id must be empty for global resource scope",
+ resources.ErrInvalidScopeBinding,
+ path,
+ )
+ }
+ *workspaceID = ""
+ case resources.ResourceScopeKindWorkspace:
+ if *domainScope == "" {
+ *domainScope = AutomationScopeWorkspace
+ }
+ if *domainScope != AutomationScopeWorkspace {
+ return fmt.Errorf(
+ "%w: %s.scope %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ path,
+ *domainScope,
+ resourceScope.Kind,
+ )
+ }
+ trimmedWorkspaceID := strings.TrimSpace(*workspaceID)
+ switch {
+ case trimmedWorkspaceID == "":
+ *workspaceID = resourceScope.ID
+ case trimmedWorkspaceID != resourceScope.ID:
+ return fmt.Errorf(
+ "%w: %s.workspace_id %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ path,
+ trimmedWorkspaceID,
+ resourceScope.ID,
+ )
+ default:
+ *workspaceID = trimmedWorkspaceID
+ }
+ }
+ return nil
+}
diff --git a/internal/automation/resource_projection.go b/internal/automation/resource_projection.go
new file mode 100644
index 000000000..6e1c7dfc1
--- /dev/null
+++ b/internal/automation/resource_projection.go
@@ -0,0 +1,981 @@
+package automation
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/store"
+)
+
+type jobResourceProjectionPlan struct {
+ revision int64
+ operations int
+ jobs []Job
+ scheduler *Scheduler
+}
+
+func (p *jobResourceProjectionPlan) Kind() resources.ResourceKind {
+ return JobResourceKind
+}
+
+func (p *jobResourceProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *jobResourceProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+type triggerResourceProjectionPlan struct {
+ revision int64
+ operations int
+ triggers []Trigger
+ engine *TriggerEngine
+}
+
+func (p *triggerResourceProjectionPlan) Kind() resources.ResourceKind {
+ return TriggerResourceKind
+}
+
+func (p *triggerResourceProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *triggerResourceProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+// BuildJobResourceState builds the next scheduler plan from canonical automation.job records.
+func (m *Manager) BuildJobResourceState(
+ ctx context.Context,
+ records []resources.Record[Job],
+) (resources.ProjectionPlan, error) {
+ if ctx == nil {
+ return nil, errors.New("automation: job resource build context is required")
+ }
+ if m == nil {
+ return nil, errors.New("automation: manager is required")
+ }
+
+ jobs := make([]Job, 0, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ job := cloneJob(record.Spec)
+ job.ID = strings.TrimSpace(record.ID)
+ job.CreatedAt = record.CreatedAt.UTC()
+ job.UpdatedAt = record.UpdatedAt.UTC()
+ jobs = append(jobs, job)
+ }
+ sortJobs(jobs)
+
+ effectiveJobs, err := m.applyJobQueryAndOverlays(ctx, jobs, JobListQuery{})
+ if err != nil {
+ return nil, err
+ }
+
+ scheduler, err := m.buildSchedulerRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := m.loadSchedulerRegistrations(effectiveJobs, scheduler); err != nil {
+ return nil, errors.Join(err, m.shutdownRuntimeComponent(ctx, "scheduler", scheduler))
+ }
+
+ return &jobResourceProjectionPlan{
+ revision: revision,
+ operations: len(effectiveJobs),
+ jobs: cloneJobs(jobs),
+ scheduler: scheduler,
+ }, nil
+}
+
+// ApplyJobResourceState atomically swaps the scheduler and desired job catalog.
+func (m *Manager) ApplyJobResourceState(ctx context.Context, plan resources.ProjectionPlan) error {
+ if ctx == nil {
+ return errors.New("automation: job resource apply context is required")
+ }
+ if m == nil {
+ return errors.New("automation: manager is required")
+ }
+
+ typed, ok := plan.(*jobResourceProjectionPlan)
+ if !ok {
+ return fmt.Errorf("automation: job resource plan has type %T", plan)
+ }
+ if typed.scheduler == nil {
+ return errors.New("automation: job resource plan scheduler is required")
+ }
+
+ m.mu.Lock()
+ running := m.running
+ m.mu.Unlock()
+
+ if running {
+ if err := typed.scheduler.Start(ctx); err != nil {
+ return errors.Join(err, m.shutdownRuntimeComponent(ctx, "scheduler", typed.scheduler))
+ }
+ }
+
+ nextJobs := jobMapFromSlice(typed.jobs)
+ if !running {
+ m.mu.Lock()
+ m.projectedJobs = nextJobs
+ m.jobRevision = typed.revision
+ m.mu.Unlock()
+ return m.shutdownRuntimeComponent(ctx, "scheduler", typed.scheduler)
+ }
+
+ m.mu.Lock()
+ if running && !m.running {
+ m.mu.Unlock()
+ return errors.Join(ErrManagerNotRunning, m.shutdownRuntimeComponent(ctx, "scheduler", typed.scheduler))
+ }
+ oldScheduler := m.scheduler
+ m.scheduler = typed.scheduler
+ m.projectedJobs = nextJobs
+ m.jobRevision = typed.revision
+ m.mu.Unlock()
+
+ if oldScheduler != nil {
+ if err := m.shutdownRuntimeComponent(ctx, "scheduler", oldScheduler); err != nil {
+ m.logger.Warn("automation.resource.job.cleanup_failed", "error", err)
+ }
+ }
+ return nil
+}
+
+// BuildTriggerResourceState builds the next trigger-engine plan from canonical automation.trigger records.
+func (m *Manager) BuildTriggerResourceState(
+ ctx context.Context,
+ records []resources.Record[Trigger],
+) (resources.ProjectionPlan, error) {
+ if ctx == nil {
+ return nil, errors.New("automation: trigger resource build context is required")
+ }
+ if m == nil {
+ return nil, errors.New("automation: manager is required")
+ }
+
+ triggers := make([]Trigger, 0, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ trigger := cloneTrigger(record.Spec)
+ trigger.ID = strings.TrimSpace(record.ID)
+ trigger.CreatedAt = record.CreatedAt.UTC()
+ trigger.UpdatedAt = record.UpdatedAt.UTC()
+ if strings.EqualFold(strings.TrimSpace(trigger.Event), "webhook") &&
+ strings.TrimSpace(trigger.WebhookID) == "" {
+ trigger.WebhookID = stableConfigID("wbh", trigger.ID)
+ }
+ triggers = append(triggers, trigger)
+ }
+ sortTriggers(triggers)
+
+ effectiveTriggers, err := m.applyTriggerQueryAndOverlays(ctx, triggers, TriggerListQuery{})
+ if err != nil {
+ return nil, err
+ }
+
+ engine, err := m.buildTriggerRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := m.loadTriggerRegistrations(ctx, effectiveTriggers, engine); err != nil {
+ return nil, errors.Join(err, m.shutdownRuntimeComponent(ctx, "trigger engine", engine))
+ }
+
+ return &triggerResourceProjectionPlan{
+ revision: revision,
+ operations: len(effectiveTriggers),
+ triggers: cloneTriggers(triggers),
+ engine: engine,
+ }, nil
+}
+
+// ApplyTriggerResourceState atomically swaps the trigger engine and desired trigger catalog.
+func (m *Manager) ApplyTriggerResourceState(ctx context.Context, plan resources.ProjectionPlan) error {
+ if ctx == nil {
+ return errors.New("automation: trigger resource apply context is required")
+ }
+ if m == nil {
+ return errors.New("automation: manager is required")
+ }
+
+ typed, ok := plan.(*triggerResourceProjectionPlan)
+ if !ok {
+ return fmt.Errorf("automation: trigger resource plan has type %T", plan)
+ }
+ if typed.engine == nil {
+ return errors.New("automation: trigger resource plan engine is required")
+ }
+
+ m.mu.Lock()
+ running := m.running
+ m.mu.Unlock()
+
+ if running {
+ if err := typed.engine.Start(ctx); err != nil {
+ return errors.Join(err, m.shutdownRuntimeComponent(ctx, "trigger engine", typed.engine))
+ }
+ }
+
+ nextTriggers := triggerMapFromSlice(typed.triggers)
+ if !running {
+ m.mu.Lock()
+ m.projectedTriggers = nextTriggers
+ m.triggerRevision = typed.revision
+ m.mu.Unlock()
+ return m.shutdownRuntimeComponent(ctx, "trigger engine", typed.engine)
+ }
+
+ m.mu.Lock()
+ if running && !m.running {
+ m.mu.Unlock()
+ return errors.Join(ErrManagerNotRunning, m.shutdownRuntimeComponent(ctx, "trigger engine", typed.engine))
+ }
+ oldEngine := m.triggers
+ m.triggers = typed.engine
+ m.projectedTriggers = nextTriggers
+ m.triggerRevision = typed.revision
+ m.mu.Unlock()
+
+ if oldEngine != nil {
+ if err := m.shutdownRuntimeComponent(ctx, "trigger engine", oldEngine); err != nil {
+ m.logger.Warn("automation.resource.trigger.cleanup_failed", "error", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) resourceDefinitionsEnabled() bool {
+ return m != nil && m.jobResources != nil && m.triggerResources != nil
+}
+
+func defaultAutomationResourceActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "automation-resource-sync",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "automation"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (m *Manager) resourceActorForSource(source JobSource) resources.MutationActor {
+ actor := m.resourceActor
+ if actor.Kind == "" {
+ actor = defaultAutomationResourceActor()
+ }
+ sourceID := "automation." + strings.TrimSpace(string(source))
+ actor.ID = sourceID
+ actor.Source = resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: sourceID}
+ return actor
+}
+
+func (m *Manager) createJobResource(ctx context.Context, job Job) (Job, error) {
+ next := cloneJob(job)
+ if next.Source == "" {
+ next.Source = JobSourceDynamic
+ }
+ if next.Source != JobSourceDynamic {
+ return Job{}, ErrDefinitionReadOnly
+ }
+ if strings.TrimSpace(next.ID) == "" {
+ next.ID = store.NewID("job")
+ }
+ next.CreatedAt = m.now().UTC()
+ next.UpdatedAt = next.CreatedAt
+ if err := next.Validate("job"); err != nil {
+ return Job{}, err
+ }
+ if _, err := m.jobResources.Put(ctx, m.resourceActorForSource(JobSourceDynamic), resources.Draft[Job]{
+ ID: next.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: next,
+ }); err != nil {
+ return Job{}, err
+ }
+ if err := m.applyJobResourcesFromStore(ctx); err != nil {
+ return Job{}, err
+ }
+ return m.effectiveJob(ctx, next.ID)
+}
+
+func (m *Manager) updateJobResource(ctx context.Context, job Job) (Job, error) {
+ current, err := m.jobResources.Get(ctx, m.resourceActor, strings.TrimSpace(job.ID))
+ if err != nil {
+ return Job{}, err
+ }
+ if current.Spec.Source != JobSourceDynamic {
+ return Job{}, ErrDefinitionReadOnly
+ }
+
+ next := cloneJob(job)
+ next.ID = current.ID
+ next.Source = current.Spec.Source
+ next.CreatedAt = current.CreatedAt.UTC()
+ next.UpdatedAt = m.now().UTC()
+ if err := next.Validate("job"); err != nil {
+ return Job{}, err
+ }
+ if _, err := m.jobResources.Put(ctx, currentResourceActor(current.Source, m.resourceActor), resources.Draft[Job]{
+ ID: current.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: current.Version,
+ Spec: next,
+ }); err != nil {
+ return Job{}, err
+ }
+ if err := m.applyJobResourcesFromStore(ctx); err != nil {
+ return Job{}, err
+ }
+ return m.effectiveJob(ctx, current.ID)
+}
+
+func (m *Manager) deleteJobResource(ctx context.Context, id string) error {
+ current, err := m.jobResources.Get(ctx, m.resourceActor, strings.TrimSpace(id))
+ if err != nil {
+ return err
+ }
+ if current.Spec.Source != JobSourceDynamic {
+ return ErrDefinitionReadOnly
+ }
+ if err := m.jobResources.Delete(
+ ctx,
+ currentResourceActor(current.Source, m.resourceActor),
+ current.ID,
+ current.Version,
+ ); err != nil {
+ return err
+ }
+ return m.applyJobResourcesFromStore(ctx)
+}
+
+func (m *Manager) createTriggerResource(ctx context.Context, trigger Trigger, webhookSecret string) (Trigger, error) {
+ next := cloneTrigger(trigger)
+ if next.Source == "" {
+ next.Source = JobSourceDynamic
+ }
+ if next.Source != JobSourceDynamic {
+ return Trigger{}, ErrDefinitionReadOnly
+ }
+ if strings.TrimSpace(next.ID) == "" {
+ next.ID = store.NewID("trg")
+ }
+ if strings.EqualFold(strings.TrimSpace(next.Event), "webhook") &&
+ strings.TrimSpace(next.WebhookID) == "" {
+ next.WebhookID = stableConfigID("wbh", next.ID)
+ }
+ next.CreatedAt = m.now().UTC()
+ next.UpdatedAt = next.CreatedAt
+ if err := next.Validate("trigger"); err != nil {
+ return Trigger{}, err
+ }
+ if err := m.syncTriggerWebhookSecret(ctx, Trigger{}, next, stringPointer(webhookSecret)); err != nil {
+ return Trigger{}, err
+ }
+ if _, err := m.triggerResources.Put(ctx, m.resourceActorForSource(JobSourceDynamic), resources.Draft[Trigger]{
+ ID: next.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: next,
+ }); err != nil {
+ return Trigger{}, errors.Join(err, m.store.DeleteTriggerWebhookSecret(ctx, next.ID))
+ }
+ if err := m.applyTriggerResourcesFromStore(ctx); err != nil {
+ return Trigger{}, err
+ }
+ return m.effectiveTrigger(ctx, next.ID)
+}
+
+func (m *Manager) updateTriggerResource(
+ ctx context.Context,
+ trigger Trigger,
+ webhookSecret *string,
+) (Trigger, error) {
+ current, err := m.triggerResources.Get(ctx, m.resourceActor, strings.TrimSpace(trigger.ID))
+ if err != nil {
+ return Trigger{}, err
+ }
+ if current.Spec.Source != JobSourceDynamic {
+ return Trigger{}, ErrDefinitionReadOnly
+ }
+ previousSecret, err := m.currentWebhookSecret(ctx, current.Spec)
+ if err != nil {
+ return Trigger{}, err
+ }
+
+ next := cloneTrigger(trigger)
+ next.ID = current.ID
+ next.Source = current.Spec.Source
+ next.CreatedAt = current.CreatedAt.UTC()
+ next.UpdatedAt = m.now().UTC()
+ if strings.EqualFold(strings.TrimSpace(next.Event), "webhook") &&
+ strings.TrimSpace(next.WebhookID) == "" {
+ next.WebhookID = stableConfigID("wbh", next.ID)
+ }
+ if err := next.Validate("trigger"); err != nil {
+ return Trigger{}, err
+ }
+ if err := m.syncTriggerWebhookSecret(ctx, current.Spec, next, webhookSecret); err != nil {
+ return Trigger{}, err
+ }
+ if _, err := m.triggerResources.Put(
+ ctx,
+ currentResourceActor(current.Source, m.resourceActor),
+ resources.Draft[Trigger]{
+ ID: current.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: current.Version,
+ Spec: next,
+ },
+ ); err != nil {
+ return Trigger{}, errors.Join(err, m.restoreWebhookSecret(ctx, current.Spec, previousSecret))
+ }
+ if err := m.applyTriggerResourcesFromStore(ctx); err != nil {
+ return Trigger{}, err
+ }
+ return m.effectiveTrigger(ctx, current.ID)
+}
+
+func (m *Manager) deleteTriggerResource(ctx context.Context, id string) error {
+ current, err := m.triggerResources.Get(ctx, m.resourceActor, strings.TrimSpace(id))
+ if err != nil {
+ return err
+ }
+ if current.Spec.Source != JobSourceDynamic {
+ return ErrDefinitionReadOnly
+ }
+ previousSecret, err := m.currentWebhookSecret(ctx, current.Spec)
+ if err != nil {
+ return err
+ }
+ if err := m.store.DeleteTriggerWebhookSecret(ctx, current.ID); err != nil {
+ return err
+ }
+ if err := m.triggerResources.Delete(
+ ctx,
+ currentResourceActor(current.Source, m.resourceActor),
+ current.ID,
+ current.Version,
+ ); err != nil {
+ return errors.Join(err, m.restoreWebhookSecret(ctx, current.Spec, previousSecret))
+ }
+ return m.applyTriggerResourcesFromStore(ctx)
+}
+
+func (m *Manager) setJobResourceEnabled(ctx context.Context, id string, enabled bool) (Job, error) {
+ current, err := m.projectedJobDefinition(id)
+ if err != nil {
+ return Job{}, err
+ }
+ if isOverlayManagedSource(current.Source) {
+ if err := m.persistJobOverlay(ctx, current, enabled); err != nil {
+ return Job{}, err
+ }
+ currentEffective, err := m.effectiveJob(ctx, current.ID)
+ if err != nil {
+ return Job{}, err
+ }
+ if err := m.applyJobToRuntime(currentEffective); err != nil {
+ return Job{}, err
+ }
+ return currentEffective, nil
+ }
+
+ current.Enabled = enabled
+ return m.updateJobResource(ctx, current)
+}
+
+func (m *Manager) setTriggerResourceEnabled(ctx context.Context, id string, enabled bool) (Trigger, error) {
+ current, err := m.projectedTriggerDefinition(id)
+ if err != nil {
+ return Trigger{}, err
+ }
+ if isOverlayManagedSource(current.Source) {
+ if err := m.persistTriggerOverlay(ctx, current, enabled); err != nil {
+ return Trigger{}, err
+ }
+ currentEffective, err := m.effectiveTrigger(ctx, current.ID)
+ if err != nil {
+ return Trigger{}, err
+ }
+ if err := m.applyTriggerToRuntime(ctx, currentEffective); err != nil {
+ return Trigger{}, err
+ }
+ return currentEffective, nil
+ }
+
+ current.Enabled = enabled
+ return m.updateTriggerResource(ctx, current, nil)
+}
+
+func (m *Manager) syncManagedResourceDefinitions(
+ ctx context.Context,
+ source JobSource,
+ desiredJobs []Job,
+ desiredTriggers []Trigger,
+ desiredTriggerSecrets map[string]string,
+) (SyncStats, error) {
+ actor := m.resourceActorForSource(source)
+
+ jobsSynced, jobsRemoved, err := m.syncJobResourcesForSource(ctx, actor, desiredJobs)
+ if err != nil {
+ return SyncStats{}, err
+ }
+ triggersSynced, triggersRemoved, err := m.syncTriggerResourcesForSource(
+ ctx,
+ actor,
+ desiredTriggers,
+ desiredTriggerSecrets,
+ )
+ if err != nil {
+ return SyncStats{}, err
+ }
+
+ if err := m.triggerResourceReconcile(ctx, JobResourceKind); err != nil {
+ return SyncStats{}, err
+ }
+ if err := m.triggerResourceReconcile(ctx, TriggerResourceKind); err != nil {
+ return SyncStats{}, err
+ }
+
+ stats := SyncStats{
+ JobsSynced: jobsSynced,
+ TriggersSynced: triggersSynced,
+ JobsRemoved: jobsRemoved,
+ TriggersRemoved: triggersRemoved,
+ SyncedAt: m.now().UTC(),
+ }
+ m.logger.Info(
+ "automation.managed.resource_sync",
+ "source", source,
+ "jobs_synced", stats.JobsSynced,
+ "triggers_synced", stats.TriggersSynced,
+ "jobs_removed", stats.JobsRemoved,
+ "triggers_removed", stats.TriggersRemoved,
+ )
+ return stats, nil
+}
+
+func (m *Manager) syncJobResourcesForSource(
+ ctx context.Context,
+ actor resources.MutationActor,
+ desired []Job,
+) (int, int, error) {
+ source := actor.Source
+ current, err := m.jobResources.List(ctx, actor, resources.ResourceFilter{
+ Kind: JobResourceKind,
+ Source: &source,
+ })
+ if err != nil {
+ return 0, 0, err
+ }
+ currentByID := make(map[string]resources.Record[Job], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ synced := 0
+ for _, job := range desired {
+ next := cloneJob(job)
+ next.Source = JobSource(strings.TrimSpace(string(next.Source)))
+ if strings.TrimSpace(next.ID) == "" {
+ return 0, 0, errors.New("automation: managed job id is required")
+ }
+ currentRecord, exists := currentByID[next.ID]
+ if exists && currentRecord.Scope == ResourceScopeForAutomation(next.Scope, next.WorkspaceID) &&
+ sameJobDefinition(currentRecord.Spec, next) {
+ delete(currentByID, next.ID)
+ synced++
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if exists {
+ expectedVersion = currentRecord.Version
+ }
+ if _, err := m.jobResources.Put(ctx, actor, resources.Draft[Job]{
+ ID: next.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: next,
+ }); err != nil {
+ return 0, 0, err
+ }
+ delete(currentByID, next.ID)
+ synced++
+ }
+
+ removed := 0
+ for _, stale := range currentByID {
+ if err := m.jobResources.Delete(ctx, actor, stale.ID, stale.Version); err != nil {
+ return 0, 0, err
+ }
+ removed++
+ }
+ return synced, removed, nil
+}
+
+func (m *Manager) syncTriggerResourcesForSource(
+ ctx context.Context,
+ actor resources.MutationActor,
+ desired []Trigger,
+ desiredSecrets map[string]string,
+) (int, int, error) {
+ source := actor.Source
+ current, err := m.triggerResources.List(ctx, actor, resources.ResourceFilter{
+ Kind: TriggerResourceKind,
+ Source: &source,
+ })
+ if err != nil {
+ return 0, 0, err
+ }
+ currentByID := make(map[string]resources.Record[Trigger], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ synced := 0
+ for _, trigger := range desired {
+ next := cloneTrigger(trigger)
+ if strings.TrimSpace(next.ID) == "" {
+ return 0, 0, errors.New("automation: managed trigger id is required")
+ }
+ if strings.EqualFold(strings.TrimSpace(next.Event), "webhook") && strings.TrimSpace(next.WebhookID) == "" {
+ next.WebhookID = stableConfigID("wbh", next.ID)
+ }
+ secret := strings.TrimSpace(desiredSecrets[next.ID])
+ if err := m.syncManagedTriggerWebhookSecret(ctx, Trigger{}, next, secret); err != nil {
+ return 0, 0, err
+ }
+
+ currentRecord, exists := currentByID[next.ID]
+ if exists && currentRecord.Scope == ResourceScopeForAutomation(next.Scope, next.WorkspaceID) &&
+ sameTriggerDefinition(currentRecord.Spec, next) {
+ delete(currentByID, next.ID)
+ synced++
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if exists {
+ expectedVersion = currentRecord.Version
+ }
+ if _, err := m.triggerResources.Put(ctx, actor, resources.Draft[Trigger]{
+ ID: next.ID,
+ Scope: ResourceScopeForAutomation(next.Scope, next.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: next,
+ }); err != nil {
+ return 0, 0, err
+ }
+ delete(currentByID, next.ID)
+ synced++
+ }
+
+ removed := 0
+ for _, stale := range currentByID {
+ if err := m.store.DeleteTriggerWebhookSecret(ctx, stale.ID); err != nil &&
+ !errors.Is(err, ErrTriggerWebhookSecretNotFound) {
+ return 0, 0, err
+ }
+ if err := m.triggerResources.Delete(ctx, actor, stale.ID, stale.Version); err != nil {
+ return 0, 0, err
+ }
+ removed++
+ }
+ return synced, removed, nil
+}
+
+func (m *Manager) applyJobResourcesFromStore(ctx context.Context) error {
+ records, err := m.jobResources.List(ctx, m.resourceActor, resources.ResourceFilter{Kind: JobResourceKind})
+ if err != nil {
+ return err
+ }
+ plan, err := m.BuildJobResourceState(ctx, records)
+ if err != nil {
+ return err
+ }
+ return m.ApplyJobResourceState(ctx, plan)
+}
+
+func (m *Manager) applyTriggerResourcesFromStore(ctx context.Context) error {
+ records, err := m.triggerResources.List(ctx, m.resourceActor, resources.ResourceFilter{Kind: TriggerResourceKind})
+ if err != nil {
+ return err
+ }
+ plan, err := m.BuildTriggerResourceState(ctx, records)
+ if err != nil {
+ return err
+ }
+ return m.ApplyTriggerResourceState(ctx, plan)
+}
+
+func (m *Manager) loadProjectedJobDefinitionsFromStore(ctx context.Context) ([]Job, int64, error) {
+ records, err := m.jobResources.List(ctx, m.resourceActor, resources.ResourceFilter{Kind: JobResourceKind})
+ if err != nil {
+ return nil, 0, err
+ }
+
+ jobs := make([]Job, 0, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ job := cloneJob(record.Spec)
+ job.ID = strings.TrimSpace(record.ID)
+ job.CreatedAt = record.CreatedAt.UTC()
+ job.UpdatedAt = record.UpdatedAt.UTC()
+ jobs = append(jobs, job)
+ }
+ sortJobs(jobs)
+ return jobs, revision, nil
+}
+
+func (m *Manager) loadProjectedTriggerDefinitionsFromStore(ctx context.Context) ([]Trigger, int64, error) {
+ records, err := m.triggerResources.List(ctx, m.resourceActor, resources.ResourceFilter{Kind: TriggerResourceKind})
+ if err != nil {
+ return nil, 0, err
+ }
+
+ triggers := make([]Trigger, 0, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ trigger := cloneTrigger(record.Spec)
+ trigger.ID = strings.TrimSpace(record.ID)
+ trigger.CreatedAt = record.CreatedAt.UTC()
+ trigger.UpdatedAt = record.UpdatedAt.UTC()
+ if strings.EqualFold(strings.TrimSpace(trigger.Event), "webhook") &&
+ strings.TrimSpace(trigger.WebhookID) == "" {
+ trigger.WebhookID = stableConfigID("wbh", trigger.ID)
+ }
+ triggers = append(triggers, trigger)
+ }
+ sortTriggers(triggers)
+ return triggers, revision, nil
+}
+
+func (m *Manager) triggerResourceReconcile(ctx context.Context, kind resources.ResourceKind) error {
+ if m == nil || m.resourceTrigger == nil {
+ return nil
+ }
+ return m.resourceTrigger(ctx, kind, resources.ReconcileReasonWrite)
+}
+
+func currentResourceActor(source resources.ResourceSource, fallback resources.MutationActor) resources.MutationActor {
+ actor := fallback
+ if actor.Kind == "" {
+ actor = defaultAutomationResourceActor()
+ }
+ actor.Source = source.Normalize()
+ actor.ID = source.ID
+ if strings.TrimSpace(actor.ID) == "" {
+ actor.ID = "automation-resource"
+ }
+ return actor
+}
+
+func (m *Manager) projectedJobDefinitions() []Job {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.projectedJobDefinitionsLocked()
+}
+
+func (m *Manager) projectedJobDefinitionsLocked() []Job {
+ jobs := make([]Job, 0, len(m.projectedJobs))
+ for _, job := range m.projectedJobs {
+ jobs = append(jobs, cloneJob(job))
+ }
+ sortJobs(jobs)
+ return jobs
+}
+
+func (m *Manager) projectedTriggerDefinitions() []Trigger {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.projectedTriggerDefinitionsLocked()
+}
+
+func (m *Manager) projectedTriggerDefinitionsLocked() []Trigger {
+ triggers := make([]Trigger, 0, len(m.projectedTriggers))
+ for _, trigger := range m.projectedTriggers {
+ triggers = append(triggers, cloneTrigger(trigger))
+ }
+ sortTriggers(triggers)
+ return triggers
+}
+
+func (m *Manager) projectedJobDefinition(id string) (Job, error) {
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" {
+ return Job{}, ErrJobNotFound
+ }
+ m.mu.RLock()
+ job, ok := m.projectedJobs[trimmedID]
+ m.mu.RUnlock()
+ if !ok {
+ return Job{}, ErrJobNotFound
+ }
+ return cloneJob(job), nil
+}
+
+func (m *Manager) projectedTriggerDefinition(id string) (Trigger, error) {
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" {
+ return Trigger{}, ErrTriggerNotFound
+ }
+ m.mu.RLock()
+ trigger, ok := m.projectedTriggers[trimmedID]
+ m.mu.RUnlock()
+ if !ok {
+ return Trigger{}, ErrTriggerNotFound
+ }
+ return cloneTrigger(trigger), nil
+}
+
+func (m *Manager) applyJobQueryAndOverlays(
+ ctx context.Context,
+ jobs []Job,
+ query JobListQuery,
+) ([]Job, error) {
+ overlays, err := m.store.ListJobEnabledOverlays(ctx)
+ if err != nil {
+ return nil, err
+ }
+ overlayByID := make(map[string]bool, len(overlays))
+ for _, overlay := range overlays {
+ overlayByID[overlay.JobID] = overlay.EnabledOverride
+ }
+
+ effective := make([]Job, 0, len(jobs))
+ for _, job := range jobs {
+ if query.Scope != "" && job.Scope != query.Scope {
+ continue
+ }
+ if query.WorkspaceID != "" && job.WorkspaceID != strings.TrimSpace(query.WorkspaceID) {
+ continue
+ }
+ if query.Source != "" && job.Source != query.Source {
+ continue
+ }
+ next := cloneJob(job)
+ if isOverlayManagedSource(next.Source) {
+ if enabled, ok := overlayByID[next.ID]; ok {
+ next.Enabled = enabled
+ }
+ }
+ effective = append(effective, next)
+ }
+ sortJobs(effective)
+ if query.Limit > 0 && len(effective) > query.Limit {
+ effective = effective[:query.Limit]
+ }
+ return effective, nil
+}
+
+func (m *Manager) applyTriggerQueryAndOverlays(
+ ctx context.Context,
+ triggers []Trigger,
+ query TriggerListQuery,
+) ([]Trigger, error) {
+ overlays, err := m.store.ListTriggerEnabledOverlays(ctx)
+ if err != nil {
+ return nil, err
+ }
+ overlayByID := make(map[string]bool, len(overlays))
+ for _, overlay := range overlays {
+ overlayByID[overlay.TriggerID] = overlay.EnabledOverride
+ }
+
+ effective := make([]Trigger, 0, len(triggers))
+ for _, trigger := range triggers {
+ if query.Scope != "" && trigger.Scope != query.Scope {
+ continue
+ }
+ if query.WorkspaceID != "" && trigger.WorkspaceID != strings.TrimSpace(query.WorkspaceID) {
+ continue
+ }
+ if query.Event != "" && trigger.Event != strings.TrimSpace(query.Event) {
+ continue
+ }
+ if query.Source != "" && trigger.Source != query.Source {
+ continue
+ }
+ next := cloneTrigger(trigger)
+ if isOverlayManagedSource(next.Source) {
+ if enabled, ok := overlayByID[next.ID]; ok {
+ next.Enabled = enabled
+ }
+ }
+ effective = append(effective, next)
+ }
+ sortTriggers(effective)
+ if query.Limit > 0 && len(effective) > query.Limit {
+ effective = effective[:query.Limit]
+ }
+ return effective, nil
+}
+
+func jobMapFromSlice(jobs []Job) map[string]Job {
+ byID := make(map[string]Job, len(jobs))
+ for _, job := range jobs {
+ byID[job.ID] = cloneJob(job)
+ }
+ return byID
+}
+
+func triggerMapFromSlice(triggers []Trigger) map[string]Trigger {
+ byID := make(map[string]Trigger, len(triggers))
+ for _, trigger := range triggers {
+ byID[trigger.ID] = cloneTrigger(trigger)
+ }
+ return byID
+}
+
+func cloneJobs(jobs []Job) []Job {
+ if len(jobs) == 0 {
+ return nil
+ }
+ cloned := make([]Job, 0, len(jobs))
+ for _, job := range jobs {
+ cloned = append(cloned, cloneJob(job))
+ }
+ return cloned
+}
+
+func cloneTriggers(triggers []Trigger) []Trigger {
+ if len(triggers) == 0 {
+ return nil
+ }
+ cloned := make([]Trigger, 0, len(triggers))
+ for _, trigger := range triggers {
+ cloned = append(cloned, cloneTrigger(trigger))
+ }
+ return cloned
+}
diff --git a/internal/automation/resource_test.go b/internal/automation/resource_test.go
new file mode 100644
index 000000000..8560a1652
--- /dev/null
+++ b/internal/automation/resource_test.go
@@ -0,0 +1,855 @@
+package automation
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/resources"
+ taskpkg "github.com/pedronauck/agh/internal/task"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestAutomationResourceCodecsRejectInvalidSpecs(t *testing.T) {
+ t.Parallel()
+
+ jobCodec, err := NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("NewJobResourceCodec() error = %v", err)
+ }
+ triggerCodec, err := NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewTriggerResourceCodec() error = %v", err)
+ }
+
+ ctx := testutil.Context(t)
+ workspaceScope := resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-resource"}
+
+ validJob := testJob(AutomationScopeWorkspace, "resource-job", "ws-resource")
+ jobWithScopeMismatch := validJob
+ jobWithScopeMismatch.WorkspaceID = "ws-other"
+ if _, err := jobCodec.DecodeAndValidate(
+ ctx,
+ workspaceScope,
+ mustAutomationJSON(t, jobWithScopeMismatch),
+ ); !errors.Is(
+ err,
+ resources.ErrInvalidScopeBinding,
+ ) {
+ t.Fatalf("job scope mismatch error = %v, want ErrInvalidScopeBinding", err)
+ } else if !strings.Contains(err.Error(), "automation: bind job resource scope") {
+ t.Fatalf("job scope mismatch error = %v, want bind job resource scope context", err)
+ }
+
+ malformedJob := validJob
+ malformedJob.Schedule = &ScheduleSpec{Mode: ScheduleModeEvery, Interval: "0s"}
+ if _, err := jobCodec.DecodeAndValidate(ctx, workspaceScope, mustAutomationJSON(t, malformedJob)); err == nil {
+ t.Fatal("job codec accepted malformed schedule")
+ } else if !strings.Contains(err.Error(), "automation: validate job resource spec") {
+ t.Fatalf("malformed job error = %v, want validate job resource spec context", err)
+ }
+
+ validTrigger := Trigger{
+ Scope: AutomationScopeWorkspace,
+ Name: "resource-trigger",
+ AgentName: "reviewer",
+ WorkspaceID: "ws-resource",
+ Prompt: `Review {{ index .Data "session_id" }}`,
+ Event: "session.stopped",
+ Enabled: true,
+ Retry: DefaultRetryConfig(),
+ FireLimit: DefaultFireLimitConfig(),
+ Source: JobSourceDynamic,
+ }
+ triggerWithBadFilter := validTrigger
+ triggerWithBadFilter.Filter = map[string]string{"unsupported": "value"}
+ if _, err := triggerCodec.DecodeAndValidate(
+ ctx,
+ workspaceScope,
+ mustAutomationJSON(t, triggerWithBadFilter),
+ ); err == nil {
+ t.Fatal("trigger codec accepted malformed filter")
+ } else if !strings.Contains(err.Error(), "automation: validate trigger resource spec") {
+ t.Fatalf("trigger filter error = %v, want validate trigger resource spec context", err)
+ }
+
+ webhookWithoutEndpoint := validTrigger
+ webhookWithoutEndpoint.Event = "webhook"
+ if _, err := triggerCodec.DecodeAndValidate(
+ ctx,
+ workspaceScope,
+ mustAutomationJSON(t, webhookWithoutEndpoint),
+ ); err == nil {
+ t.Fatal("trigger codec accepted webhook without endpoint_slug or webhook_id")
+ } else if !strings.Contains(err.Error(), "automation: validate trigger resource spec") {
+ t.Fatalf("webhook trigger error = %v, want validate trigger resource spec context", err)
+ }
+}
+
+func TestManagerStartRegistersResourceDefinitionsAtStartup(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ jobRecord := h.putJobResource(t, "job-startup", "startup-job")
+ triggerRecord := h.putTriggerResource(t, "trigger-startup", "startup-trigger")
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ jobs, err := manager.Jobs(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Jobs() error = %v", err)
+ }
+ if got := findJobByID(jobs, jobRecord.ID); got == nil {
+ t.Fatalf("jobs missing resource-backed job %q after Start", jobRecord.ID)
+ }
+ if got, want := len(manager.scheduler.States()), 1; got != want {
+ t.Fatalf("len(manager.scheduler.States()) = %d, want %d", got, want)
+ }
+
+ triggers, err := manager.Triggers(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Triggers() error = %v", err)
+ }
+ if got := findTriggerByID(triggers, triggerRecord.ID); got == nil {
+ t.Fatalf("triggers missing resource-backed trigger %q after Start", triggerRecord.ID)
+ }
+ manager.triggers.mu.RLock()
+ registered := len(manager.triggers.registrations)
+ manager.triggers.mu.RUnlock()
+ if got, want := registered, 1; got != want {
+ t.Fatalf("len(manager.triggers.registrations) = %d, want %d", got, want)
+ }
+}
+
+func TestAutomationJobResourceBuildDoesNotMutateLiveRuntime(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ record := h.putJobResource(t, "job-side-effect", "side-effect-job")
+ plan, err := manager.BuildJobResourceState(h.ctx, []resources.Record[Job]{record})
+ if err != nil {
+ t.Fatalf("BuildJobResourceState() error = %v", err)
+ }
+ if plan.Kind() != JobResourceKind || plan.Revision() != record.Version || plan.OperationCount() != 1 {
+ t.Fatalf(
+ "job plan metadata = kind:%q revision:%d operations:%d",
+ plan.Kind(),
+ plan.Revision(),
+ plan.OperationCount(),
+ )
+ }
+ shutdownJobResourcePlan(t, manager, plan)
+
+ jobs, err := manager.Jobs(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Jobs() error = %v", err)
+ }
+ if len(jobs) != 0 {
+ t.Fatalf("manager.Jobs() after Build = %d, want 0", len(jobs))
+ }
+ if got := manager.scheduler.States(); len(got) != 0 {
+ t.Fatalf("scheduler states after Build = %d, want 0", len(got))
+ }
+}
+
+func TestAutomationTriggerResourceBuildDoesNotMutateLiveRuntime(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ record := h.putTriggerResource(t, "trigger-side-effect", "side-effect-trigger")
+ plan, err := manager.BuildTriggerResourceState(h.ctx, []resources.Record[Trigger]{record})
+ if err != nil {
+ t.Fatalf("BuildTriggerResourceState() error = %v", err)
+ }
+ if plan.Kind() != TriggerResourceKind || plan.Revision() != record.Version || plan.OperationCount() != 1 {
+ t.Fatalf(
+ "trigger plan metadata = kind:%q revision:%d operations:%d",
+ plan.Kind(),
+ plan.Revision(),
+ plan.OperationCount(),
+ )
+ }
+ shutdownTriggerResourcePlan(t, manager, plan)
+
+ triggers, err := manager.Triggers(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Triggers() error = %v", err)
+ }
+ if len(triggers) != 0 {
+ t.Fatalf("manager.Triggers() after Build = %d, want 0", len(triggers))
+ }
+ manager.triggers.mu.RLock()
+ registered := len(manager.triggers.registrations)
+ manager.triggers.mu.RUnlock()
+ if registered != 0 {
+ t.Fatalf("trigger registrations after Build = %d, want 0", registered)
+ }
+}
+
+func TestAutomationJobResourceApplyFailurePreservesPreviousRuntime(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ first := h.putJobResource(t, "job-previous", "previous-job")
+ firstPlan, err := manager.BuildJobResourceState(h.ctx, []resources.Record[Job]{first})
+ if err != nil {
+ t.Fatalf("BuildJobResourceState(previous) error = %v", err)
+ }
+ if err := manager.ApplyJobResourceState(h.ctx, firstPlan); err != nil {
+ t.Fatalf("ApplyJobResourceState(previous) error = %v", err)
+ }
+
+ next := h.putJobResource(t, "job-next", "next-job")
+ nextPlan, err := manager.BuildJobResourceState(h.ctx, []resources.Record[Job]{next})
+ if err != nil {
+ t.Fatalf("BuildJobResourceState(next) error = %v", err)
+ }
+ canceledCtx, cancel := context.WithCancel(h.ctx)
+ cancel()
+ if err := manager.ApplyJobResourceState(canceledCtx, nextPlan); !errors.Is(err, context.Canceled) {
+ t.Fatalf("ApplyJobResourceState(canceled) error = %v, want context.Canceled", err)
+ }
+
+ jobs, err := manager.Jobs(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Jobs() error = %v", err)
+ }
+ if got := findJobByID(jobs, first.ID); got == nil {
+ t.Fatalf("previous job %q missing after failed Apply", first.ID)
+ }
+ if got := findJobByID(jobs, next.ID); got != nil {
+ t.Fatalf("next job %q applied after failed Apply", next.ID)
+ }
+}
+
+func TestAutomationTriggerResourceApplyFailurePreservesPreviousRuntime(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ first := h.putTriggerResource(t, "trigger-previous", "previous-trigger")
+ firstPlan, err := manager.BuildTriggerResourceState(h.ctx, []resources.Record[Trigger]{first})
+ if err != nil {
+ t.Fatalf("BuildTriggerResourceState(previous) error = %v", err)
+ }
+ if err := manager.ApplyTriggerResourceState(h.ctx, firstPlan); err != nil {
+ t.Fatalf("ApplyTriggerResourceState(previous) error = %v", err)
+ }
+
+ next := h.putTriggerResource(t, "trigger-next", "next-trigger")
+ nextPlan, err := manager.BuildTriggerResourceState(h.ctx, []resources.Record[Trigger]{next})
+ if err != nil {
+ t.Fatalf("BuildTriggerResourceState(next) error = %v", err)
+ }
+ canceledCtx, cancel := context.WithCancel(h.ctx)
+ cancel()
+ if err := manager.ApplyTriggerResourceState(canceledCtx, nextPlan); !errors.Is(err, context.Canceled) {
+ t.Fatalf("ApplyTriggerResourceState(canceled) error = %v, want context.Canceled", err)
+ }
+
+ triggers, err := manager.Triggers(h.ctx)
+ if err != nil {
+ t.Fatalf("manager.Triggers() error = %v", err)
+ }
+ if got := findTriggerByID(triggers, first.ID); got == nil {
+ t.Fatalf("previous trigger %q missing after failed Apply", first.ID)
+ }
+ if got := findTriggerByID(triggers, next.ID); got != nil {
+ t.Fatalf("next trigger %q applied after failed Apply", next.ID)
+ }
+}
+
+func TestLegacyAutomationDefinitionWritesDoNotDriveResourceManager(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ legacyJob, err := h.db.CreateJob(h.ctx, testJob(AutomationScopeGlobal, "legacy-job", ""))
+ if err != nil {
+ t.Fatalf("CreateJob(legacy) error = %v", err)
+ }
+ if _, err := manager.GetJob(h.ctx, legacyJob.ID); !errors.Is(err, ErrJobNotFound) {
+ t.Fatalf("manager.GetJob(legacy) error = %v, want ErrJobNotFound", err)
+ }
+
+ resourceRecord := h.putJobResource(t, "job-resource", "resource-job")
+ plan, err := manager.BuildJobResourceState(h.ctx, []resources.Record[Job]{resourceRecord})
+ if err != nil {
+ t.Fatalf("BuildJobResourceState(resource) error = %v", err)
+ }
+ if err := manager.ApplyJobResourceState(h.ctx, plan); err != nil {
+ t.Fatalf("ApplyJobResourceState(resource) error = %v", err)
+ }
+ if _, err := manager.GetJob(h.ctx, resourceRecord.ID); err != nil {
+ t.Fatalf("manager.GetJob(resource) error = %v", err)
+ }
+}
+
+func TestAutomationResourceProjectionRejectsInvalidInputs(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+
+ if _, err := manager.BuildJobResourceState(nilAutomationResourceContext(), nil); err == nil {
+ t.Fatal("BuildJobResourceState(nil context) error = nil, want error")
+ }
+ var nilManager *Manager
+ if _, err := nilManager.BuildJobResourceState(h.ctx, nil); err == nil {
+ t.Fatal("nil manager BuildJobResourceState() error = nil, want error")
+ }
+ if err := manager.ApplyJobResourceState(nilAutomationResourceContext(), nil); err == nil {
+ t.Fatal("ApplyJobResourceState(nil context) error = nil, want error")
+ }
+ if err := nilManager.ApplyJobResourceState(h.ctx, nil); err == nil {
+ t.Fatal("nil manager ApplyJobResourceState() error = nil, want error")
+ }
+ if err := manager.ApplyJobResourceState(h.ctx, &triggerResourceProjectionPlan{}); err == nil {
+ t.Fatal("ApplyJobResourceState(wrong plan type) error = nil, want error")
+ }
+ if err := manager.ApplyJobResourceState(h.ctx, &jobResourceProjectionPlan{}); err == nil {
+ t.Fatal("ApplyJobResourceState(missing scheduler) error = nil, want error")
+ }
+
+ if _, err := manager.BuildTriggerResourceState(nilAutomationResourceContext(), nil); err == nil {
+ t.Fatal("BuildTriggerResourceState(nil context) error = nil, want error")
+ }
+ if _, err := nilManager.BuildTriggerResourceState(h.ctx, nil); err == nil {
+ t.Fatal("nil manager BuildTriggerResourceState() error = nil, want error")
+ }
+ if err := manager.ApplyTriggerResourceState(nilAutomationResourceContext(), nil); err == nil {
+ t.Fatal("ApplyTriggerResourceState(nil context) error = nil, want error")
+ }
+ if err := nilManager.ApplyTriggerResourceState(h.ctx, nil); err == nil {
+ t.Fatal("nil manager ApplyTriggerResourceState() error = nil, want error")
+ }
+ if err := manager.ApplyTriggerResourceState(h.ctx, &jobResourceProjectionPlan{}); err == nil {
+ t.Fatal("ApplyTriggerResourceState(wrong plan type) error = nil, want error")
+ }
+ if err := manager.ApplyTriggerResourceState(h.ctx, &triggerResourceProjectionPlan{}); err == nil {
+ t.Fatal("ApplyTriggerResourceState(missing engine) error = nil, want error")
+ }
+}
+
+func TestAutomationResourceManagerCRUDUsesTypedResourceStores(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ createdJob, err := manager.CreateJob(h.ctx, testJob(AutomationScopeGlobal, "resource-crud-job", ""))
+ if err != nil {
+ t.Fatalf("CreateJob(resource) error = %v", err)
+ }
+ if createdJob.Source != JobSourceDynamic {
+ t.Fatalf("created job source = %q, want %q", createdJob.Source, JobSourceDynamic)
+ }
+ jobRecord, err := h.jobStore.Get(h.ctx, h.actor, createdJob.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(created) error = %v", err)
+ }
+ if jobRecord.Spec.Name != createdJob.Name {
+ t.Fatalf("job resource name = %q, want %q", jobRecord.Spec.Name, createdJob.Name)
+ }
+
+ nextJob := createdJob
+ nextJob.Prompt = "Review the resource-backed scheduler"
+ updatedJob, err := manager.UpdateJob(h.ctx, nextJob)
+ if err != nil {
+ t.Fatalf("UpdateJob(resource) error = %v", err)
+ }
+ if updatedJob.Prompt != nextJob.Prompt {
+ t.Fatalf("updated job prompt = %q, want %q", updatedJob.Prompt, nextJob.Prompt)
+ }
+ disabledJob, err := manager.SetJobEnabled(h.ctx, createdJob.ID, false)
+ if err != nil {
+ t.Fatalf("SetJobEnabled(resource) error = %v", err)
+ }
+ if disabledJob.Enabled {
+ t.Fatal("SetJobEnabled(resource) returned enabled job, want disabled")
+ }
+ jobRecord, err = h.jobStore.Get(h.ctx, h.actor, createdJob.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(disabled) error = %v", err)
+ }
+ if jobRecord.Spec.Enabled {
+ t.Fatal("resource job spec enabled = true, want false")
+ }
+ if err := manager.DeleteJob(h.ctx, createdJob.ID); err != nil {
+ t.Fatalf("DeleteJob(resource) error = %v", err)
+ }
+ if _, err := manager.GetJob(h.ctx, createdJob.ID); !errors.Is(err, ErrJobNotFound) {
+ t.Fatalf("GetJob(deleted resource) error = %v, want ErrJobNotFound", err)
+ }
+ if _, err := h.jobStore.Get(h.ctx, h.actor, createdJob.ID); !errors.Is(err, resources.ErrNotFound) {
+ t.Fatalf("jobStore.Get(deleted) error = %v, want resources.ErrNotFound", err)
+ }
+
+ trigger := testTrigger(AutomationScopeGlobal, "resource-crud-trigger", "")
+ trigger.Event = "session.stopped"
+ trigger.WebhookID = ""
+ createdTrigger, err := manager.CreateTrigger(h.ctx, trigger, "")
+ if err != nil {
+ t.Fatalf("CreateTrigger(resource) error = %v", err)
+ }
+ triggerRecord, err := h.triggerStore.Get(h.ctx, h.actor, createdTrigger.ID)
+ if err != nil {
+ t.Fatalf("triggerStore.Get(created) error = %v", err)
+ }
+ if triggerRecord.Spec.Event != "session.stopped" {
+ t.Fatalf("trigger resource event = %q, want session.stopped", triggerRecord.Spec.Event)
+ }
+
+ nextTrigger := createdTrigger
+ nextTrigger.Prompt = `Review stopped session {{ index .Data "session_id" }}`
+ updatedTrigger, err := manager.UpdateTrigger(h.ctx, nextTrigger, nil)
+ if err != nil {
+ t.Fatalf("UpdateTrigger(resource) error = %v", err)
+ }
+ if updatedTrigger.Prompt != nextTrigger.Prompt {
+ t.Fatalf("updated trigger prompt = %q, want %q", updatedTrigger.Prompt, nextTrigger.Prompt)
+ }
+ disabledTrigger, err := manager.SetTriggerEnabled(h.ctx, createdTrigger.ID, false)
+ if err != nil {
+ t.Fatalf("SetTriggerEnabled(resource) error = %v", err)
+ }
+ if disabledTrigger.Enabled {
+ t.Fatal("SetTriggerEnabled(resource) returned enabled trigger, want disabled")
+ }
+ triggerRecord, err = h.triggerStore.Get(h.ctx, h.actor, createdTrigger.ID)
+ if err != nil {
+ t.Fatalf("triggerStore.Get(disabled) error = %v", err)
+ }
+ if triggerRecord.Spec.Enabled {
+ t.Fatal("resource trigger spec enabled = true, want false")
+ }
+ if err := manager.DeleteTrigger(h.ctx, createdTrigger.ID); err != nil {
+ t.Fatalf("DeleteTrigger(resource) error = %v", err)
+ }
+ if _, err := manager.GetTrigger(h.ctx, createdTrigger.ID); !errors.Is(err, ErrTriggerNotFound) {
+ t.Fatalf("GetTrigger(deleted resource) error = %v, want ErrTriggerNotFound", err)
+ }
+ if _, err := h.triggerStore.Get(h.ctx, h.actor, createdTrigger.ID); !errors.Is(err, resources.ErrNotFound) {
+ t.Fatalf("triggerStore.Get(deleted) error = %v, want resources.ErrNotFound", err)
+ }
+}
+
+func TestAutomationResourceSyncManagedDefinitionsPublishesAndPrunesSourceRecords(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ var triggered []resources.ResourceKind
+ manager := h.newResourceManager(t, WithResourceDefinitions(
+ h.jobStore,
+ h.triggerStore,
+ h.actor,
+ func(_ context.Context, kind resources.ResourceKind, _ resources.ReconcileReason) error {
+ triggered = append(triggered, kind.Normalize())
+ return nil
+ },
+ ))
+
+ job := testJob(AutomationScopeGlobal, "managed-resource-job", "")
+ trigger := testTrigger(AutomationScopeGlobal, "managed-resource-trigger", "")
+ trigger.Event = "session.stopped"
+ trigger.WebhookID = ""
+
+ stats, err := manager.SyncManagedDefinitions(
+ h.ctx,
+ JobSourceConfig,
+ []Job{job},
+ []Trigger{trigger},
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("SyncManagedDefinitions(create) error = %v", err)
+ }
+ if stats.JobsSynced != 1 || stats.TriggersSynced != 1 || stats.JobsRemoved != 0 || stats.TriggersRemoved != 0 {
+ t.Fatalf("create stats = %#v", stats)
+ }
+
+ configActor := manager.resourceActorForSource(JobSourceConfig)
+ jobRecord, err := h.jobStore.Get(h.ctx, configActor, job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(config) error = %v", err)
+ }
+ if jobRecord.Spec.Source != JobSourceConfig || jobRecord.Spec.Name != job.Name {
+ t.Fatalf("config job resource = %#v", jobRecord.Spec)
+ }
+ triggerRecord, err := h.triggerStore.Get(h.ctx, configActor, trigger.ID)
+ if err != nil {
+ t.Fatalf("triggerStore.Get(config) error = %v", err)
+ }
+ if triggerRecord.Spec.Source != JobSourceConfig || triggerRecord.Spec.Event != "session.stopped" {
+ t.Fatalf("config trigger resource = %#v", triggerRecord.Spec)
+ }
+
+ job.Prompt = "Review the updated config resource"
+ stats, err = manager.SyncManagedDefinitions(h.ctx, JobSourceConfig, []Job{job}, nil, nil)
+ if err != nil {
+ t.Fatalf("SyncManagedDefinitions(update) error = %v", err)
+ }
+ if stats.JobsSynced != 1 || stats.TriggersSynced != 0 || stats.JobsRemoved != 0 || stats.TriggersRemoved != 1 {
+ t.Fatalf("update stats = %#v", stats)
+ }
+ jobRecord, err = h.jobStore.Get(h.ctx, configActor, job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(updated config) error = %v", err)
+ }
+ if jobRecord.Spec.Prompt != job.Prompt {
+ t.Fatalf("updated config job prompt = %q, want %q", jobRecord.Spec.Prompt, job.Prompt)
+ }
+ if _, err := h.triggerStore.Get(h.ctx, configActor, trigger.ID); !errors.Is(err, resources.ErrNotFound) {
+ t.Fatalf("triggerStore.Get(pruned config) error = %v, want resources.ErrNotFound", err)
+ }
+ if len(triggered) != 4 {
+ t.Fatalf("resource reconcile triggers = %#v, want two kinds for each sync", triggered)
+ }
+}
+
+func TestAutomationResourceSyncManagedDefinitionsSkipsUnchangedTaskBackedJob(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+
+ job := testJob(AutomationScopeGlobal, "task-backed-resource-job", "")
+ job.AgentName = ""
+ job.Prompt = ""
+ job.Retry = RetryConfig{Strategy: RetryStrategyNone}
+ job.Task = &JobTaskConfig{
+ Title: "Run task-backed automation",
+ Description: "Exercise task equality in managed resource sync",
+ NetworkChannel: "builders",
+ Owner: &taskpkg.Ownership{Kind: taskpkg.OwnerKindPool, Ref: "ops"},
+ }
+
+ if _, err := manager.SyncManagedDefinitions(h.ctx, JobSourceConfig, []Job{job}, nil, nil); err != nil {
+ t.Fatalf("SyncManagedDefinitions(first) error = %v", err)
+ }
+ configActor := manager.resourceActorForSource(JobSourceConfig)
+ firstRecord, err := h.jobStore.Get(h.ctx, configActor, job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(first) error = %v", err)
+ }
+
+ stats, err := manager.SyncManagedDefinitions(h.ctx, JobSourceConfig, []Job{job}, nil, nil)
+ if err != nil {
+ t.Fatalf("SyncManagedDefinitions(second) error = %v", err)
+ }
+ if stats.JobsSynced != 1 || stats.JobsRemoved != 0 || stats.TriggersSynced != 0 || stats.TriggersRemoved != 0 {
+ t.Fatalf("second sync stats = %#v", stats)
+ }
+ secondRecord, err := h.jobStore.Get(h.ctx, configActor, job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(second) error = %v", err)
+ }
+ if secondRecord.Version != firstRecord.Version {
+ t.Fatalf("unchanged task-backed job version = %d, want %d", secondRecord.Version, firstRecord.Version)
+ }
+ if secondRecord.Spec.Task == nil || secondRecord.Spec.Task.Owner == nil {
+ t.Fatalf("task-backed job resource lost task owner: %#v", secondRecord.Spec.Task)
+ }
+}
+
+func TestAutomationResourceConfigEnabledChangesUseOperationalOverlays(t *testing.T) {
+ t.Parallel()
+
+ h := newManagerResourceHarness(t)
+ manager := h.newResourceManager(t)
+ if err := manager.Start(h.ctx); err != nil {
+ t.Fatalf("manager.Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("manager.Shutdown() error = %v", err)
+ }
+ })
+
+ job := testJob(AutomationScopeGlobal, "config-resource-job", "")
+ trigger := testTrigger(AutomationScopeGlobal, "config-resource-trigger", "")
+ trigger.Event = "session.stopped"
+ trigger.WebhookID = ""
+ if _, err := manager.SyncManagedDefinitions(
+ h.ctx,
+ JobSourceConfig,
+ []Job{job},
+ []Trigger{trigger},
+ nil,
+ ); err != nil {
+ t.Fatalf("SyncManagedDefinitions(config) error = %v", err)
+ }
+ if err := manager.applyJobResourcesFromStore(h.ctx); err != nil {
+ t.Fatalf("applyJobResourcesFromStore() error = %v", err)
+ }
+ if err := manager.applyTriggerResourcesFromStore(h.ctx); err != nil {
+ t.Fatalf("applyTriggerResourcesFromStore() error = %v", err)
+ }
+
+ disabledJob, err := manager.SetJobEnabled(h.ctx, job.ID, false)
+ if err != nil {
+ t.Fatalf("SetJobEnabled(config resource) error = %v", err)
+ }
+ if disabledJob.Enabled {
+ t.Fatal("config resource job effective enabled = true, want false overlay")
+ }
+ configActor := manager.resourceActorForSource(JobSourceConfig)
+ jobRecord, err := h.jobStore.Get(h.ctx, configActor, job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get(config resource) error = %v", err)
+ }
+ if !jobRecord.Spec.Enabled {
+ t.Fatal("config resource job spec enabled = false, want unchanged true")
+ }
+ jobOverlay, err := h.db.GetJobEnabledOverlay(h.ctx, job.ID)
+ if err != nil {
+ t.Fatalf("GetJobEnabledOverlay(config resource) error = %v", err)
+ }
+ if jobOverlay.EnabledOverride {
+ t.Fatal("config job overlay enabled_override = true, want false")
+ }
+
+ disabledTrigger, err := manager.SetTriggerEnabled(h.ctx, trigger.ID, false)
+ if err != nil {
+ t.Fatalf("SetTriggerEnabled(config resource) error = %v", err)
+ }
+ if disabledTrigger.Enabled {
+ t.Fatal("config resource trigger effective enabled = true, want false overlay")
+ }
+ triggerRecord, err := h.triggerStore.Get(h.ctx, configActor, trigger.ID)
+ if err != nil {
+ t.Fatalf("triggerStore.Get(config resource) error = %v", err)
+ }
+ if !triggerRecord.Spec.Enabled {
+ t.Fatal("config resource trigger spec enabled = false, want unchanged true")
+ }
+ triggerOverlay, err := h.db.GetTriggerEnabledOverlay(h.ctx, trigger.ID)
+ if err != nil {
+ t.Fatalf("GetTriggerEnabledOverlay(config resource) error = %v", err)
+ }
+ if triggerOverlay.EnabledOverride {
+ t.Fatal("config trigger overlay enabled_override = true, want false")
+ }
+}
+
+type managerResourceHarness struct {
+ *managerHarness
+ jobStore resources.Store[Job]
+ triggerStore resources.Store[Trigger]
+ actor resources.MutationActor
+}
+
+func newManagerResourceHarness(t *testing.T) *managerResourceHarness {
+ t.Helper()
+
+ base := newManagerHarness(t)
+ kernel, err := resources.NewKernel(base.db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ jobCodec, err := NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("NewJobResourceCodec() error = %v", err)
+ }
+ jobStore, err := resources.NewStore(kernel, jobCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(job) error = %v", err)
+ }
+ triggerCodec, err := NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewTriggerResourceCodec() error = %v", err)
+ }
+ triggerStore, err := resources.NewStore(kernel, triggerCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(trigger) error = %v", err)
+ }
+
+ return &managerResourceHarness{
+ managerHarness: base,
+ jobStore: jobStore,
+ triggerStore: triggerStore,
+ actor: resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "automation-resource-test",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "automation-resource-test",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ }
+}
+
+func (h *managerResourceHarness) newResourceManager(t *testing.T, opts ...Option) *Manager {
+ t.Helper()
+
+ resourceOpts := []Option{
+ WithResourceDefinitions(h.jobStore, h.triggerStore, h.actor, nil),
+ }
+ resourceOpts = append(resourceOpts, opts...)
+ return h.newManager(t, defaultAutomationTestConfig(), resourceOpts...)
+}
+
+func (h *managerResourceHarness) putJobResource(t *testing.T, id string, name string) resources.Record[Job] {
+ t.Helper()
+
+ runAt := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
+ job := Job{
+ Scope: AutomationScopeGlobal,
+ Name: name,
+ AgentName: "reviewer",
+ Prompt: "Review repository",
+ Schedule: &ScheduleSpec{Mode: ScheduleModeAt, Time: runAt},
+ Enabled: true,
+ Retry: DefaultRetryConfig(),
+ FireLimit: DefaultFireLimitConfig(),
+ Source: JobSourceDynamic,
+ }
+ record, err := h.jobStore.Put(testutil.Context(t), h.actor, resources.Draft[Job]{
+ ID: id,
+ Scope: ResourceScopeForAutomation(job.Scope, job.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: job,
+ })
+ if err != nil {
+ t.Fatalf("jobStore.Put(%q) error = %v", id, err)
+ }
+ return record
+}
+
+func (h *managerResourceHarness) putTriggerResource(t *testing.T, id string, name string) resources.Record[Trigger] {
+ t.Helper()
+
+ trigger := Trigger{
+ Scope: AutomationScopeGlobal,
+ Name: name,
+ AgentName: "reviewer",
+ Prompt: `Review {{ index .Data "session_id" }}`,
+ Event: "session.stopped",
+ Enabled: true,
+ Retry: DefaultRetryConfig(),
+ FireLimit: DefaultFireLimitConfig(),
+ Source: JobSourceDynamic,
+ }
+ record, err := h.triggerStore.Put(testutil.Context(t), h.actor, resources.Draft[Trigger]{
+ ID: id,
+ Scope: ResourceScopeForAutomation(trigger.Scope, trigger.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: trigger,
+ })
+ if err != nil {
+ t.Fatalf("triggerStore.Put(%q) error = %v", id, err)
+ }
+ return record
+}
+
+func defaultAutomationTestConfig() aghconfig.AutomationConfig {
+ return aghconfig.AutomationConfig{
+ Enabled: true,
+ Timezone: DefaultTimezone,
+ MaxConcurrentJobs: DefaultMaxConcurrentJobs,
+ DefaultFireLimit: DefaultFireLimitConfig(),
+ }
+}
+
+func mustAutomationJSON(t *testing.T, value any) []byte {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ return raw
+}
+
+func nilAutomationResourceContext() context.Context {
+ return nil
+}
+
+func shutdownJobResourcePlan(t *testing.T, manager *Manager, plan resources.ProjectionPlan) {
+ t.Helper()
+ typed, ok := plan.(*jobResourceProjectionPlan)
+ if !ok || typed.scheduler == nil {
+ return
+ }
+ if err := manager.shutdownRuntimeComponent(testutil.Context(t), "scheduler", typed.scheduler); err != nil {
+ t.Fatalf("shutdown job resource plan scheduler error = %v", err)
+ }
+}
+
+func shutdownTriggerResourcePlan(t *testing.T, manager *Manager, plan resources.ProjectionPlan) {
+ t.Helper()
+ typed, ok := plan.(*triggerResourceProjectionPlan)
+ if !ok || typed.engine == nil {
+ return
+ }
+ if err := manager.shutdownRuntimeComponent(testutil.Context(t), "trigger engine", typed.engine); err != nil {
+ t.Fatalf("shutdown trigger resource plan engine error = %v", err)
+ }
+}
diff --git a/internal/bridges/managed_sync.go b/internal/bridges/managed_sync.go
index d3eeb58d8..466cd3238 100644
--- a/internal/bridges/managed_sync.go
+++ b/internal/bridges/managed_sync.go
@@ -1,13 +1,14 @@
package bridges
import (
- "bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
+
+ "github.com/pedronauck/agh/internal/resources"
)
// ManagedSyncStore is the persistence surface required to reconcile one
@@ -36,6 +37,51 @@ type ManagedSyncStats struct {
SyncedAt time.Time
}
+// ManagedResourceSyncService reconciles one managed bridge source into canonical resources.
+type ManagedResourceSyncService struct {
+ store resources.Store[BridgeInstanceSpec]
+ actor resources.MutationActor
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ now func() time.Time
+}
+
+// ManagedResourceSyncOption customizes ManagedResourceSyncService construction.
+type ManagedResourceSyncOption func(*ManagedResourceSyncService)
+
+var _ ManagedSyncer = (*ManagedResourceSyncService)(nil)
+
+// NewManagedResourceSyncer constructs a managed bridge reconciler over canonical bridge.instance resources.
+func NewManagedResourceSyncer(
+ store resources.Store[BridgeInstanceSpec],
+ actor resources.MutationActor,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+ opts ...ManagedResourceSyncOption,
+) *ManagedResourceSyncService {
+ service := &ManagedResourceSyncService{
+ store: store,
+ actor: actor,
+ trigger: trigger,
+ now: func() time.Time {
+ return time.Now().UTC()
+ },
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(service)
+ }
+ }
+ return service
+}
+
+// WithManagedResourceSyncNow overrides the resource sync clock in tests.
+func WithManagedResourceSyncNow(now func() time.Time) ManagedResourceSyncOption {
+ return func(service *ManagedResourceSyncService) {
+ if now != nil {
+ service.now = now
+ }
+ }
+}
+
// ManagedSyncService reconciles one managed bridge source directly against the
// persisted bridge-instance catalog.
type ManagedSyncService struct {
@@ -73,6 +119,192 @@ func WithManagedSyncNow(now func() time.Time) ManagedSyncOption {
}
}
+// SyncManagedInstances reconciles managed bridge resources for one source exactly.
+func (s *ManagedResourceSyncService) SyncManagedInstances(
+ ctx context.Context,
+ source BridgeInstanceSource,
+ desired []BridgeInstance,
+) (ManagedSyncStats, error) {
+ actor, normalizedSource, err := s.validateResourceSyncInputs(ctx, source)
+ if err != nil {
+ return ManagedSyncStats{}, err
+ }
+
+ existing, err := s.listManagedResourceInstances(ctx, actor)
+ if err != nil {
+ return ManagedSyncStats{}, err
+ }
+ desiredByID, synced, err := s.syncDesiredManagedResources(ctx, actor, normalizedSource, existing, desired)
+ if err != nil {
+ return ManagedSyncStats{}, err
+ }
+ removed, err := s.deleteStaleManagedResources(ctx, actor, existing, desiredByID)
+ if err != nil {
+ return ManagedSyncStats{}, err
+ }
+ if synced > 0 || removed > 0 {
+ if err := s.triggerResourceReconcile(ctx); err != nil {
+ return ManagedSyncStats{}, err
+ }
+ }
+ return ManagedSyncStats{
+ InstancesSynced: synced,
+ InstancesRemoved: removed,
+ SyncedAt: s.now().UTC(),
+ }, nil
+}
+
+func (s *ManagedResourceSyncService) validateResourceSyncInputs(
+ ctx context.Context,
+ source BridgeInstanceSource,
+) (resources.MutationActor, BridgeInstanceSource, error) {
+ if s == nil {
+ return resources.MutationActor{}, "", errors.New("bridges: managed resource sync service is required")
+ }
+ if ctx == nil {
+ return resources.MutationActor{}, "", errors.New("bridges: managed resource sync context is required")
+ }
+ normalizedSource := source.Normalize()
+ if err := normalizedSource.Validate(); err != nil {
+ return resources.MutationActor{}, "", err
+ }
+ if s.store == nil {
+ return resources.MutationActor{}, "", errors.New("bridges: managed resource sync store is required")
+ }
+ return bridgeResourceActorForSource(s.actor, normalizedSource), normalizedSource, nil
+}
+
+func (s *ManagedResourceSyncService) listManagedResourceInstances(
+ ctx context.Context,
+ actor resources.MutationActor,
+) (map[string]resources.Record[BridgeInstanceSpec], error) {
+ source := actor.Source.Normalize()
+ records, err := s.store.List(ctx, actor, resources.ResourceFilter{
+ Kind: BridgeInstanceResourceKind,
+ Source: &source,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("bridges: reconcile list managed bridge resources: %w", err)
+ }
+ byID := make(map[string]resources.Record[BridgeInstanceSpec], len(records))
+ for _, record := range records {
+ byID[record.ID] = record
+ }
+ return byID, nil
+}
+
+func (s *ManagedResourceSyncService) syncDesiredManagedResources(
+ ctx context.Context,
+ actor resources.MutationActor,
+ source BridgeInstanceSource,
+ existingByID map[string]resources.Record[BridgeInstanceSpec],
+ desired []BridgeInstance,
+) (map[string]BridgeInstanceSpec, int, error) {
+ desiredByID := make(map[string]BridgeInstanceSpec, len(desired))
+ synced := 0
+ for _, instance := range desired {
+ id, spec, err := s.prepareDesiredManagedResource(source, desiredByID, instance)
+ if err != nil {
+ return nil, 0, err
+ }
+ current, exists := existingByID[id]
+ if exists && current.Scope == ResourceScopeForBridge(spec.Scope, spec.WorkspaceID) &&
+ sameBridgeInstanceSpec(current.Spec, spec) {
+ desiredByID[id] = spec
+ synced++
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if exists {
+ expectedVersion = current.Version
+ }
+ if _, err := s.store.Put(ctx, actor, resources.Draft[BridgeInstanceSpec]{
+ ID: id,
+ Scope: ResourceScopeForBridge(spec.Scope, spec.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: spec,
+ }); err != nil {
+ return nil, 0, fmt.Errorf(
+ "bridges: reconcile upsert %q bridge resource %q: %w",
+ source,
+ strings.TrimSpace(id),
+ err,
+ )
+ }
+ desiredByID[id] = spec
+ synced++
+ }
+ return desiredByID, synced, nil
+}
+
+func (s *ManagedResourceSyncService) prepareDesiredManagedResource(
+ source BridgeInstanceSource,
+ desiredByID map[string]BridgeInstanceSpec,
+ instance BridgeInstance,
+) (string, BridgeInstanceSpec, error) {
+ next := instance
+ next.Source = source
+ next.CreatedAt = time.Time{}
+ next.UpdatedAt = time.Time{}
+ if strings.TrimSpace(next.ID) == "" {
+ return "", BridgeInstanceSpec{}, errors.New("bridges: managed bridge resource id is required")
+ }
+ if _, exists := desiredByID[next.ID]; exists {
+ return "", BridgeInstanceSpec{}, fmt.Errorf(
+ "bridges: duplicate desired managed bridge resource %q",
+ strings.TrimSpace(next.ID),
+ )
+ }
+ spec := BridgeInstanceSpecFromInstance(next)
+ return strings.TrimSpace(next.ID), spec, nil
+}
+
+func (s *ManagedResourceSyncService) deleteStaleManagedResources(
+ ctx context.Context,
+ actor resources.MutationActor,
+ existingByID map[string]resources.Record[BridgeInstanceSpec],
+ desiredByID map[string]BridgeInstanceSpec,
+) (int, error) {
+ removed := 0
+ for id, stale := range existingByID {
+ if _, ok := desiredByID[id]; ok {
+ continue
+ }
+ if err := s.store.Delete(ctx, actor, stale.ID, stale.Version); err != nil {
+ return 0, fmt.Errorf("bridges: reconcile delete managed bridge resource %q: %w", id, err)
+ }
+ removed++
+ }
+ return removed, nil
+}
+
+func (s *ManagedResourceSyncService) triggerResourceReconcile(ctx context.Context) error {
+ if s == nil || s.trigger == nil {
+ return nil
+ }
+ return s.trigger(ctx, BridgeInstanceResourceKind, resources.ReconcileReasonWrite)
+}
+
+func bridgeResourceActorForSource(
+ base resources.MutationActor,
+ source BridgeInstanceSource,
+) resources.MutationActor {
+ actor := base
+ if actor.Kind == "" {
+ actor.Kind = resources.MutationActorKindDaemon
+ }
+ actor.ID = "bridge." + strings.TrimSpace(string(source.Normalize()))
+ actor.Source = resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: actor.ID,
+ }
+ if actor.MaxScope.Kind == "" {
+ actor.MaxScope = resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ }
+ return actor
+}
+
// SyncManagedInstances reconciles all persisted bridge instances for one source
// so they match the desired set exactly.
func (s *ManagedSyncService) SyncManagedInstances(
@@ -265,15 +497,44 @@ func sameManagedInstance(left BridgeInstance, right BridgeInstance) bool {
left.Source == right.Source &&
left.Enabled == right.Enabled &&
left.Status == right.Status &&
+ left.DMPolicy == right.DMPolicy &&
left.RoutingPolicy == right.RoutingPolicy &&
+ managedSyncJSONEqual(left.ProviderConfig, right.ProviderConfig) &&
managedSyncJSONEqual(left.DeliveryDefaults, right.DeliveryDefaults)
}
-func managedSyncJSONEqual(left json.RawMessage, right json.RawMessage) bool {
- leftNormalized, leftErr := normalizeRawJSON(left, "bridge instance delivery defaults")
- rightNormalized, rightErr := normalizeRawJSON(right, "bridge instance delivery defaults")
- if leftErr != nil || rightErr != nil {
- return strings.TrimSpace(string(left)) == strings.TrimSpace(string(right))
+func sameBridgeInstanceSpec(left BridgeInstanceSpec, right BridgeInstanceSpec) bool {
+ left = normalizeBridgeInstanceResourceSpec(left)
+ right = normalizeBridgeInstanceResourceSpec(right)
+ return left.Scope == right.Scope &&
+ left.WorkspaceID == right.WorkspaceID &&
+ left.Platform == right.Platform &&
+ left.ExtensionName == right.ExtensionName &&
+ left.DisplayName == right.DisplayName &&
+ left.Source == right.Source &&
+ left.Enabled == right.Enabled &&
+ left.DMPolicy == right.DMPolicy &&
+ left.RoutingPolicy == right.RoutingPolicy &&
+ managedSyncJSONEqual(left.ProviderConfig, right.ProviderConfig) &&
+ managedSyncJSONEqual(left.DeliveryDefaults, right.DeliveryDefaults) &&
+ slicesEqualBridgeSecretSlots(left.SecretSlots, right.SecretSlots) &&
+ sameBridgeProviderConfigSchema(left.ConfigSchema, right.ConfigSchema)
+}
+
+func slicesEqualBridgeSecretSlots(left []BridgeSecretSlot, right []BridgeSecretSlot) bool {
+ left = normalizeBridgeSecretSlotsForResource(left)
+ right = normalizeBridgeSecretSlotsForResource(right)
+ if len(left) != len(right) {
+ return false
+ }
+ for idx := range left {
+ if left[idx] != right[idx] {
+ return false
+ }
}
- return bytes.Equal(leftNormalized, rightNormalized)
+ return true
+}
+
+func managedSyncJSONEqual(left json.RawMessage, right json.RawMessage) bool {
+ return semanticJSONEqual(left, right)
}
diff --git a/internal/bridges/managed_sync_test.go b/internal/bridges/managed_sync_test.go
index e83adbff4..588d1e1b5 100644
--- a/internal/bridges/managed_sync_test.go
+++ b/internal/bridges/managed_sync_test.go
@@ -111,6 +111,81 @@ func TestManagedSyncerReconcilesCreateUpdateDelete(t *testing.T) {
}
}
+func TestManagedSyncerIgnoresSemanticallyEquivalentJSON(t *testing.T) {
+ t.Parallel()
+
+ store := stubRegistryStore{
+ listBridgeInstancesFn: func(_ context.Context) ([]bridgepkg.BridgeInstance, error) {
+ return []bridgepkg.BridgeInstance{{
+ ID: "brg-json",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-adapter",
+ DisplayName: "JSON Bridge",
+ Source: bridgepkg.BridgeInstanceSourcePackage,
+ Enabled: false,
+ Status: bridgepkg.BridgeStatusDisabled,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme","features":{"beta":true}}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-1","mode":"reply"}`),
+ CreatedAt: time.Date(2026, 4, 14, 18, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 14, 18, 0, 0, 0, time.UTC),
+ }}, nil
+ },
+ }
+
+ var (
+ inserted []bridgepkg.BridgeInstance
+ updated []bridgepkg.BridgeInstance
+ deleted []string
+ )
+ store.insertBridgeInstanceFn = func(_ context.Context, instance bridgepkg.BridgeInstance) error {
+ inserted = append(inserted, instance)
+ return nil
+ }
+ store.updateBridgeInstanceFn = func(_ context.Context, instance bridgepkg.BridgeInstance) error {
+ updated = append(updated, instance)
+ return nil
+ }
+ store.deleteBridgeInstanceFn = func(_ context.Context, id string) error {
+ deleted = append(deleted, id)
+ return nil
+ }
+
+ syncer := bridgepkg.NewManagedSyncer(store)
+ stats, err := syncer.SyncManagedInstances(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceSourcePackage,
+ []bridgepkg.BridgeInstance{{
+ ID: "brg-json",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-adapter",
+ DisplayName: "JSON Bridge",
+ Enabled: false,
+ Status: bridgepkg.BridgeStatusDisabled,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte("{\n \"features\": {\"beta\": true},\n \"tenant\": \"acme\"\n}"),
+ DeliveryDefaults: []byte(`{"mode":"reply","peer_id":"peer-1"}`),
+ }},
+ )
+ if err != nil {
+ t.Fatalf("SyncManagedInstances() error = %v", err)
+ }
+ if got, want := stats.InstancesSynced, 1; got != want {
+ t.Fatalf("InstancesSynced = %d, want %d", got, want)
+ }
+ if got := len(inserted); got != 0 {
+ t.Fatalf("len(inserted) = %d, want 0", got)
+ }
+ if got := len(updated); got != 0 {
+ t.Fatalf("len(updated) = %d, want 0", got)
+ }
+ if got := len(deleted); got != 0 {
+ t.Fatalf("len(deleted) = %d, want 0", got)
+ }
+}
+
func TestManagedSyncerWrapsStoreErrors(t *testing.T) {
t.Parallel()
diff --git a/internal/bridges/resource.go b/internal/bridges/resource.go
new file mode 100644
index 000000000..a24448a65
--- /dev/null
+++ b/internal/bridges/resource.go
@@ -0,0 +1,507 @@
+package bridges
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/store"
+)
+
+const (
+ // BridgeInstanceResourceKind is the canonical desired-state kind for bridge instances.
+ BridgeInstanceResourceKind resources.ResourceKind = "bridge.instance"
+
+ bridgeInstanceResourceMaxBytes = 256 << 10
+)
+
+// BridgeProviderLookup resolves provider-authored bridge manifest metadata for resource validation.
+type BridgeProviderLookup func(context.Context, string) (BridgeProvider, bool, error)
+
+// BridgeInstanceSpec is the canonical desired-state payload for bridge.instance records.
+//
+// Runtime status, degradation, routes, delivery state, and assigned-instance reporting stay in
+// the bridge runtime store. This spec carries only desired configuration plus provider manifest
+// metadata that must be validated with the provider before persistence.
+type BridgeInstanceSpec struct {
+ Scope Scope `json:"scope,omitempty"`
+ WorkspaceID string `json:"workspace_id,omitempty"`
+ Platform string `json:"platform"`
+ ExtensionName string `json:"extension_name"`
+ DisplayName string `json:"display_name"`
+ Source BridgeInstanceSource `json:"source,omitempty"`
+ Enabled bool `json:"enabled"`
+ DMPolicy BridgeDMPolicy `json:"dm_policy,omitempty"`
+ RoutingPolicy RoutingPolicy `json:"routing_policy"`
+ ProviderConfig json.RawMessage `json:"provider_config,omitempty"`
+ DeliveryDefaults json.RawMessage `json:"delivery_defaults,omitempty"`
+ SecretSlots []BridgeSecretSlot `json:"secret_slots,omitempty"`
+ ConfigSchema *BridgeProviderConfigSchema `json:"config_schema,omitempty"`
+}
+
+// NewBridgeInstanceResourceCodec builds the typed codec for bridge.instance records.
+func NewBridgeInstanceResourceCodec(
+ providerLookup BridgeProviderLookup,
+) (resources.KindCodec[BridgeInstanceSpec], error) {
+ validator := func(
+ ctx context.Context,
+ scope resources.ResourceScope,
+ spec BridgeInstanceSpec,
+ ) (BridgeInstanceSpec, error) {
+ return validateBridgeInstanceResourceSpec(ctx, scope, spec, providerLookup)
+ }
+ return resources.NewJSONCodec(BridgeInstanceResourceKind, bridgeInstanceResourceMaxBytes, validator)
+}
+
+// ResourceScopeForBridge converts bridge scope fields into the shared resource scope.
+func ResourceScopeForBridge(scope Scope, workspaceID string) resources.ResourceScope {
+ switch scope.Normalize() {
+ case ScopeWorkspace:
+ return resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: strings.TrimSpace(workspaceID),
+ }
+ default:
+ return resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ }
+}
+
+// BridgeInstanceSpecFromCreateRequest converts a transport/domain create request into desired resource state.
+func BridgeInstanceSpecFromCreateRequest(
+ req CreateInstanceRequest,
+ now func() time.Time,
+) (string, BridgeInstanceSpec, error) {
+ instance, err := req.toInstance(now)
+ if err != nil {
+ return "", BridgeInstanceSpec{}, err
+ }
+ return instance.ID, BridgeInstanceSpecFromInstance(instance), nil
+}
+
+// BridgeInstanceSpecFromInstance strips bridge-owned operational fields from a bridge instance.
+func BridgeInstanceSpecFromInstance(instance BridgeInstance) BridgeInstanceSpec {
+ normalized := instance.normalize()
+ return BridgeInstanceSpec{
+ Scope: normalized.Scope,
+ WorkspaceID: normalized.WorkspaceID,
+ Platform: normalized.Platform,
+ ExtensionName: normalized.ExtensionName,
+ DisplayName: normalized.DisplayName,
+ Source: normalized.Source,
+ Enabled: normalized.Enabled,
+ DMPolicy: normalized.DMPolicy,
+ RoutingPolicy: normalized.RoutingPolicy,
+ ProviderConfig: cloneRawJSON(normalized.ProviderConfig),
+ DeliveryDefaults: cloneRawJSON(normalized.DeliveryDefaults),
+ }
+}
+
+func validateBridgeInstanceResourceSpec(
+ ctx context.Context,
+ scope resources.ResourceScope,
+ spec BridgeInstanceSpec,
+ providerLookup BridgeProviderLookup,
+) (BridgeInstanceSpec, error) {
+ if ctx == nil {
+ return BridgeInstanceSpec{}, errors.New("bridges: bridge resource validation context is required")
+ }
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+
+ next := normalizeBridgeInstanceResourceSpec(spec)
+ if err := bindBridgeResourceScope(&next.Scope, &next.WorkspaceID, normalizedScope); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ var err error
+ next, err = validateBridgeInstanceDesiredFields(next)
+ if err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := validateBridgeProviderMetadata(ctx, &next, providerLookup); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ return next, nil
+}
+
+func normalizeBridgeInstanceResourceSpec(spec BridgeInstanceSpec) BridgeInstanceSpec {
+ next := spec
+ next.Scope = next.Scope.Normalize()
+ next.WorkspaceID = strings.TrimSpace(next.WorkspaceID)
+ next.Platform = strings.TrimSpace(next.Platform)
+ next.ExtensionName = strings.TrimSpace(next.ExtensionName)
+ next.DisplayName = strings.TrimSpace(next.DisplayName)
+ next.Source = next.Source.Normalize()
+ if next.Source == "" {
+ next.Source = BridgeInstanceSourceDynamic
+ }
+ next.DMPolicy = next.DMPolicy.Normalize()
+ if next.DMPolicy == "" {
+ next.DMPolicy = BridgeDMPolicyOpen
+ }
+ next.ProviderConfig = bytes.TrimSpace(next.ProviderConfig)
+ next.DeliveryDefaults = bytes.TrimSpace(next.DeliveryDefaults)
+ next.SecretSlots = normalizeBridgeSecretSlotsForResource(next.SecretSlots)
+ if next.ConfigSchema != nil {
+ normalized := next.ConfigSchema.Normalize()
+ if normalized.IsZero() {
+ next.ConfigSchema = nil
+ } else {
+ next.ConfigSchema = &normalized
+ }
+ }
+ return next
+}
+
+func bindBridgeResourceScope(
+ domainScope *Scope,
+ workspaceID *string,
+ resourceScope resources.ResourceScope,
+) error {
+ switch resourceScope.Kind {
+ case resources.ResourceScopeKindGlobal:
+ if *domainScope == "" {
+ *domainScope = ScopeGlobal
+ }
+ if *domainScope != ScopeGlobal {
+ return fmt.Errorf(
+ "%w: bridge.scope %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ *domainScope,
+ resourceScope.Kind,
+ )
+ }
+ if strings.TrimSpace(*workspaceID) != "" {
+ return fmt.Errorf(
+ "%w: bridge.workspace_id must be empty for global resource scope",
+ resources.ErrInvalidScopeBinding,
+ )
+ }
+ *workspaceID = ""
+ case resources.ResourceScopeKindWorkspace:
+ if *domainScope == "" {
+ *domainScope = ScopeWorkspace
+ }
+ if *domainScope != ScopeWorkspace {
+ return fmt.Errorf(
+ "%w: bridge.scope %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ *domainScope,
+ resourceScope.Kind,
+ )
+ }
+ trimmedWorkspaceID := strings.TrimSpace(*workspaceID)
+ switch {
+ case trimmedWorkspaceID == "":
+ *workspaceID = resourceScope.ID
+ case trimmedWorkspaceID != resourceScope.ID:
+ return fmt.Errorf(
+ "%w: bridge.workspace_id %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ trimmedWorkspaceID,
+ resourceScope.ID,
+ )
+ default:
+ *workspaceID = trimmedWorkspaceID
+ }
+ }
+ return nil
+}
+
+func validateBridgeInstanceDesiredFields(spec BridgeInstanceSpec) (BridgeInstanceSpec, error) {
+ if err := ValidateScopeWorkspaceID(spec.Scope, spec.WorkspaceID); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := requireField(spec.Platform, "bridge instance platform"); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := requireField(spec.ExtensionName, "bridge instance extension name"); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := requireField(spec.DisplayName, "bridge instance display name"); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := spec.Source.Validate(); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := spec.DMPolicy.Validate(); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ if err := spec.RoutingPolicy.Validate(); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ providerConfig, err := normalizeOptionalJSONObject(spec.ProviderConfig, "bridge instance provider config")
+ if err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ spec.ProviderConfig = providerConfig
+ deliveryDefaults, err := NormalizeDeliveryDefaultsJSON(spec.DeliveryDefaults)
+ if err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ spec.DeliveryDefaults = deliveryDefaults
+ for _, slot := range spec.SecretSlots {
+ if err := slot.Validate(); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ }
+ if spec.ConfigSchema != nil {
+ if err := spec.ConfigSchema.Validate(); err != nil {
+ return BridgeInstanceSpec{}, err
+ }
+ }
+ return spec, nil
+}
+
+func validateBridgeProviderMetadata(
+ ctx context.Context,
+ spec *BridgeInstanceSpec,
+ providerLookup BridgeProviderLookup,
+) error {
+ if providerLookup == nil {
+ return nil
+ }
+ provider, ok, err := providerLookup(ctx, spec.ExtensionName)
+ if err != nil {
+ return fmt.Errorf("bridges: lookup bridge provider %q: %w", spec.ExtensionName, err)
+ }
+ if !ok {
+ return fmt.Errorf("bridges: bridge provider %q is not installed", spec.ExtensionName)
+ }
+
+ expectedPlatform := strings.TrimSpace(provider.Platform)
+ if expectedPlatform == "" {
+ return fmt.Errorf("bridges: bridge provider %q has no platform", spec.ExtensionName)
+ }
+ if spec.Platform != expectedPlatform {
+ return fmt.Errorf(
+ "bridges: bridge provider %q platform %q does not match resource platform %q",
+ spec.ExtensionName,
+ expectedPlatform,
+ spec.Platform,
+ )
+ }
+
+ expectedSlots := normalizeBridgeSecretSlotsForResource(provider.SecretSlots)
+ if len(spec.SecretSlots) == 0 {
+ spec.SecretSlots = expectedSlots
+ } else if !slices.Equal(spec.SecretSlots, expectedSlots) {
+ return fmt.Errorf(
+ "bridges: bridge provider %q secret_slots metadata does not match manifest",
+ spec.ExtensionName,
+ )
+ }
+
+ expectedSchema := normalizeBridgeProviderConfigSchemaPointer(provider.ConfigSchema)
+ if spec.ConfigSchema == nil {
+ spec.ConfigSchema = expectedSchema
+ } else if !sameBridgeProviderConfigSchema(spec.ConfigSchema, expectedSchema) {
+ return fmt.Errorf(
+ "bridges: bridge provider %q config_schema metadata does not match manifest",
+ spec.ExtensionName,
+ )
+ }
+ return nil
+}
+
+func normalizeBridgeSecretSlotsForResource(slots []BridgeSecretSlot) []BridgeSecretSlot {
+ if len(slots) == 0 {
+ return nil
+ }
+ normalized := make([]BridgeSecretSlot, 0, len(slots))
+ for _, slot := range slots {
+ next := slot.Normalize()
+ if next.Name == "" && next.Description == "" && !next.Required {
+ continue
+ }
+ normalized = append(normalized, next)
+ }
+ slices.SortFunc(normalized, func(left BridgeSecretSlot, right BridgeSecretSlot) int {
+ if byName := strings.Compare(left.Name, right.Name); byName != 0 {
+ return byName
+ }
+ return strings.Compare(left.Description, right.Description)
+ })
+ return normalized
+}
+
+func normalizeBridgeProviderConfigSchemaPointer(
+ schema *BridgeProviderConfigSchema,
+) *BridgeProviderConfigSchema {
+ if schema == nil {
+ return nil
+ }
+ normalized := schema.Normalize()
+ if normalized.IsZero() {
+ return nil
+ }
+ return &normalized
+}
+
+func sameBridgeProviderConfigSchema(left *BridgeProviderConfigSchema, right *BridgeProviderConfigSchema) bool {
+ left = normalizeBridgeProviderConfigSchemaPointer(left)
+ right = normalizeBridgeProviderConfigSchemaPointer(right)
+ switch {
+ case left == nil && right == nil:
+ return true
+ case left == nil || right == nil:
+ return false
+ default:
+ return *left == *right
+ }
+}
+
+func normalizeOptionalJSONObject(raw json.RawMessage, label string) (json.RawMessage, error) {
+ normalized, err := normalizeRawJSON(raw, label)
+ if err != nil {
+ return nil, err
+ }
+ if len(normalized) == 0 || bytes.Equal(normalized, []byte("null")) {
+ return nil, nil
+ }
+ if normalized[0] != '{' {
+ return nil, fmt.Errorf("bridges: %s must be a JSON object or null", label)
+ }
+ return normalized, nil
+}
+
+// NormalizeDeliveryDefaultsJSON validates and canonicalizes bridge delivery default JSON.
+func NormalizeDeliveryDefaultsJSON(raw json.RawMessage) (json.RawMessage, error) {
+ normalized, err := normalizeRawJSON(raw, "bridge instance delivery defaults")
+ if err != nil {
+ return nil, err
+ }
+ if len(normalized) == 0 || bytes.Equal(normalized, []byte("null")) {
+ return nil, nil
+ }
+
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(normalized, &fields); err != nil {
+ return nil, fmt.Errorf("bridges: bridge instance delivery defaults must be a JSON object or null: %w", err)
+ }
+
+ defaults := deliveryTargetDefaults{}
+ for key, value := range fields {
+ text, fieldErr := requireDeliveryDefaultStringField(value, key)
+ if fieldErr != nil {
+ return nil, fieldErr
+ }
+ switch key {
+ case "peer_id":
+ defaults.PeerID = strings.TrimSpace(text)
+ case "thread_id":
+ defaults.ThreadID = strings.TrimSpace(text)
+ case "group_id":
+ defaults.GroupID = strings.TrimSpace(text)
+ case "mode":
+ defaults.Mode = DeliveryMode(text).Normalize()
+ if err := defaults.Mode.Validate(); err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("bridges: bridge instance delivery defaults field %q is not supported", key)
+ }
+ }
+
+ defaults = defaults.normalize()
+ if defaults.ThreadID != "" && defaults.PeerID == "" && defaults.GroupID == "" {
+ return nil, errors.New("bridges: bridge instance delivery defaults thread_id requires peer_id or group_id")
+ }
+ return json.Marshal(defaults)
+}
+
+func requireDeliveryDefaultStringField(raw json.RawMessage, field string) (string, error) {
+ var decoded any
+ if err := json.Unmarshal(raw, &decoded); err != nil {
+ return "", fmt.Errorf("bridges: bridge instance delivery defaults field %q must be valid JSON: %w", field, err)
+ }
+ text, ok := decoded.(string)
+ if !ok {
+ return "", fmt.Errorf("bridges: bridge instance delivery defaults field %q must be a string", field)
+ }
+ return text, nil
+}
+
+func bridgeInstanceFromResourceRecord(
+ record resources.Record[BridgeInstanceSpec],
+ existing *BridgeInstance,
+ now func() time.Time,
+) (BridgeInstance, error) {
+ clock := now
+ if clock == nil {
+ clock = func() time.Time { return time.Now().UTC() }
+ }
+
+ timestamp := record.UpdatedAt.UTC()
+ if timestamp.IsZero() {
+ timestamp = clock()
+ }
+ createdAt := record.CreatedAt.UTC()
+ if createdAt.IsZero() {
+ createdAt = timestamp
+ }
+
+ instance := BridgeInstance{
+ ID: strings.TrimSpace(record.ID),
+ Scope: record.Spec.Scope,
+ WorkspaceID: record.Spec.WorkspaceID,
+ Platform: record.Spec.Platform,
+ ExtensionName: record.Spec.ExtensionName,
+ DisplayName: record.Spec.DisplayName,
+ Source: record.Spec.Source,
+ Enabled: record.Spec.Enabled,
+ Status: bridgeStatusForProjectedRecord(record.Spec.Enabled, existing),
+ DMPolicy: record.Spec.DMPolicy,
+ RoutingPolicy: record.Spec.RoutingPolicy,
+ ProviderConfig: cloneRawJSON(record.Spec.ProviderConfig),
+ DeliveryDefaults: cloneRawJSON(record.Spec.DeliveryDefaults),
+ CreatedAt: createdAt,
+ UpdatedAt: timestamp,
+ }
+ if existing != nil && record.Spec.Enabled {
+ instance.Degradation = cloneBridgeDegradationPointer(existing.Degradation)
+ }
+ if !record.Spec.Enabled {
+ instance.Degradation = nil
+ }
+ if instance.ID == "" {
+ instance.ID = store.NewID("brg")
+ }
+ instance = instance.normalize()
+ if err := instance.Validate(); err != nil {
+ return BridgeInstance{}, err
+ }
+ return instance, nil
+}
+
+func bridgeStatusForProjectedRecord(enabled bool, existing *BridgeInstance) BridgeStatus {
+ if !enabled {
+ return BridgeStatusDisabled
+ }
+ if existing == nil {
+ return BridgeStatusStarting
+ }
+ status := existing.Status.Normalize()
+ if status == "" || status == BridgeStatusDisabled {
+ return BridgeStatusStarting
+ }
+ return status
+}
+
+func cloneBridgeDegradationPointer(value *BridgeDegradation) *BridgeDegradation {
+ if value == nil {
+ return nil
+ }
+ cloned := value.normalize()
+ if cloned.IsZero() {
+ return nil
+ }
+ return &cloned
+}
diff --git a/internal/bridges/resource_projection.go b/internal/bridges/resource_projection.go
new file mode 100644
index 000000000..9fe9ac8b0
--- /dev/null
+++ b/internal/bridges/resource_projection.go
@@ -0,0 +1,319 @@
+package bridges
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+// ResourceProjectionStore is the bridge desired-runtime surface updated by resource projection.
+type ResourceProjectionStore interface {
+ ListBridgeInstances(ctx context.Context) ([]BridgeInstance, error)
+ ReplaceBridgeInstances(ctx context.Context, instances []BridgeInstance) error
+}
+
+// ResourceProjectionPlan is the validated bridge.instance delta built from canonical resources.
+type ResourceProjectionPlan struct {
+ revision int64
+ operations int
+ previous []BridgeInstance
+ next []BridgeInstance
+ changedExtensions []string
+}
+
+var _ resources.ProjectionPlan = (*ResourceProjectionPlan)(nil)
+
+// Kind returns the projected resource kind.
+func (p *ResourceProjectionPlan) Kind() resources.ResourceKind {
+ return BridgeInstanceResourceKind
+}
+
+// Revision returns the highest source resource version represented by this plan.
+func (p *ResourceProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+// OperationCount returns the number of runtime rows that change when this plan applies.
+func (p *ResourceProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+// PreviousInstances returns the daemon-visible bridge state before this plan applies.
+func (p *ResourceProjectionPlan) PreviousInstances() []BridgeInstance {
+ if p == nil {
+ return nil
+ }
+ return cloneBridgeInstances(p.previous)
+}
+
+// NextInstances returns the daemon-visible bridge state after this plan applies.
+func (p *ResourceProjectionPlan) NextInstances() []BridgeInstance {
+ if p == nil {
+ return nil
+ }
+ return cloneBridgeInstances(p.next)
+}
+
+// ChangedExtensions returns the provider extensions impacted by this plan.
+func (p *ResourceProjectionPlan) ChangedExtensions() []string {
+ if p == nil {
+ return nil
+ }
+ return append([]string(nil), p.changedExtensions...)
+}
+
+// RollbackPlan returns a plan that restores the prior daemon-visible bridge state.
+func (p *ResourceProjectionPlan) RollbackPlan() *ResourceProjectionPlan {
+ if p == nil {
+ return nil
+ }
+ return &ResourceProjectionPlan{
+ revision: p.revision,
+ operations: len(p.previous),
+ previous: cloneBridgeInstances(p.next),
+ next: cloneBridgeInstances(p.previous),
+ changedExtensions: append([]string(nil), p.changedExtensions...),
+ }
+}
+
+// BuildResourceState computes the next bridge runtime projection without opening live provider connections.
+func BuildResourceState(
+ ctx context.Context,
+ store ResourceProjectionStore,
+ records []resources.Record[BridgeInstanceSpec],
+ now func() time.Time,
+) (*ResourceProjectionPlan, error) {
+ if ctx == nil {
+ return nil, errors.New("bridges: bridge resource build context is required")
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if store == nil {
+ return nil, errors.New("bridges: bridge resource projection store is required")
+ }
+
+ previous, err := store.ListBridgeInstances(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("bridges: build bridge resource state: list existing instances: %w", err)
+ }
+ sortBridgeInstances(previous)
+ previousByID := bridgeInstancesByID(previous)
+
+ next := make([]BridgeInstance, 0, len(records))
+ seen := make(map[string]struct{}, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ id := strings.TrimSpace(record.ID)
+ if id == "" {
+ return nil, errors.New("bridges: bridge resource record id is required")
+ }
+ if _, ok := seen[id]; ok {
+ return nil, fmt.Errorf("bridges: duplicate bridge resource record %q", id)
+ }
+ seen[id] = struct{}{}
+
+ var existing *BridgeInstance
+ if previousInstance, ok := previousByID[id]; ok {
+ existing = cloneBridgeInstance(previousInstance)
+ }
+ instance, err := bridgeInstanceFromResourceRecord(record, existing, now)
+ if err != nil {
+ return nil, fmt.Errorf("bridges: build bridge resource state for %q: %w", id, err)
+ }
+ next = append(next, instance)
+ }
+ sortBridgeInstances(next)
+
+ return &ResourceProjectionPlan{
+ revision: revision,
+ operations: bridgeProjectionOperationCount(previous, next),
+ previous: cloneBridgeInstances(previous),
+ next: cloneBridgeInstances(next),
+ changedExtensions: changedBridgeProjectionExtensions(previous, next),
+ }, nil
+}
+
+// ApplyResourceState atomically swaps the daemon-visible bridge desired runtime state.
+func ApplyResourceState(ctx context.Context, store ResourceProjectionStore, plan resources.ProjectionPlan) error {
+ if ctx == nil {
+ return errors.New("bridges: bridge resource apply context is required")
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if store == nil {
+ return errors.New("bridges: bridge resource projection store is required")
+ }
+
+ typed, ok := plan.(*ResourceProjectionPlan)
+ if !ok {
+ return fmt.Errorf("bridges: bridge resource plan has type %T", plan)
+ }
+ if err := store.ReplaceBridgeInstances(ctx, typed.NextInstances()); err != nil {
+ return fmt.Errorf("bridges: apply bridge resource state: replace instances: %w", err)
+ }
+ return nil
+}
+
+func bridgeInstancesByID(instances []BridgeInstance) map[string]BridgeInstance {
+ byID := make(map[string]BridgeInstance, len(instances))
+ for _, instance := range instances {
+ byID[instance.ID] = *cloneBridgeInstance(instance)
+ }
+ return byID
+}
+
+func bridgeProjectionOperationCount(previous []BridgeInstance, next []BridgeInstance) int {
+ previousByID := bridgeInstancesByID(previous)
+ nextByID := bridgeInstancesByID(next)
+ operations := 0
+ for id, nextInstance := range nextByID {
+ previousInstance, exists := previousByID[id]
+ if !exists || !sameProjectedBridgeInstance(previousInstance, nextInstance) {
+ operations++
+ }
+ }
+ for id := range previousByID {
+ if _, exists := nextByID[id]; !exists {
+ operations++
+ }
+ }
+ return operations
+}
+
+func changedBridgeProjectionExtensions(previous []BridgeInstance, next []BridgeInstance) []string {
+ previousByID := bridgeInstancesByID(previous)
+ nextByID := bridgeInstancesByID(next)
+ changed := make(map[string]struct{})
+ for id, nextInstance := range nextByID {
+ previousInstance, exists := previousByID[id]
+ if exists && sameProjectedBridgeInstance(previousInstance, nextInstance) {
+ continue
+ }
+ if previousInstance.ExtensionName != "" {
+ changed[previousInstance.ExtensionName] = struct{}{}
+ }
+ if nextInstance.ExtensionName != "" {
+ changed[nextInstance.ExtensionName] = struct{}{}
+ }
+ }
+ for id, previousInstance := range previousByID {
+ if _, exists := nextByID[id]; exists {
+ continue
+ }
+ if previousInstance.ExtensionName != "" {
+ changed[previousInstance.ExtensionName] = struct{}{}
+ }
+ }
+
+ names := make([]string, 0, len(changed))
+ for name := range changed {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+ return names
+}
+
+func sameProjectedBridgeInstance(left BridgeInstance, right BridgeInstance) bool {
+ left = left.normalize()
+ right = right.normalize()
+ return left.ID == right.ID &&
+ left.Scope == right.Scope &&
+ left.WorkspaceID == right.WorkspaceID &&
+ left.Platform == right.Platform &&
+ left.ExtensionName == right.ExtensionName &&
+ left.DisplayName == right.DisplayName &&
+ left.Source == right.Source &&
+ left.Enabled == right.Enabled &&
+ left.Status == right.Status &&
+ left.DMPolicy == right.DMPolicy &&
+ left.RoutingPolicy == right.RoutingPolicy &&
+ rawJSONEqual(left.ProviderConfig, right.ProviderConfig) &&
+ rawJSONEqual(left.DeliveryDefaults, right.DeliveryDefaults) &&
+ sameBridgeDegradation(left.Degradation, right.Degradation)
+}
+
+func sameBridgeDegradation(left *BridgeDegradation, right *BridgeDegradation) bool {
+ left = cloneBridgeDegradationPointer(left)
+ right = cloneBridgeDegradationPointer(right)
+ switch {
+ case left == nil && right == nil:
+ return true
+ case left == nil || right == nil:
+ return false
+ default:
+ return *left == *right
+ }
+}
+
+func rawJSONEqual(left []byte, right []byte) bool {
+ return semanticJSONEqual(left, right)
+}
+
+func semanticJSONEqual(left []byte, right []byte) bool {
+ left = bytes.TrimSpace(left)
+ right = bytes.TrimSpace(right)
+ if len(left) == 0 || bytes.Equal(left, []byte("null")) {
+ left = nil
+ }
+ if len(right) == 0 || bytes.Equal(right, []byte("null")) {
+ right = nil
+ }
+ switch {
+ case len(left) == 0 && len(right) == 0:
+ return true
+ case len(left) == 0 || len(right) == 0:
+ return false
+ }
+
+ var leftValue any
+ if err := json.Unmarshal(left, &leftValue); err != nil {
+ return false
+ }
+ var rightValue any
+ if err := json.Unmarshal(right, &rightValue); err != nil {
+ return false
+ }
+ return reflect.DeepEqual(leftValue, rightValue)
+}
+
+func cloneBridgeInstances(instances []BridgeInstance) []BridgeInstance {
+ if len(instances) == 0 {
+ return nil
+ }
+ cloned := make([]BridgeInstance, 0, len(instances))
+ for _, instance := range instances {
+ cloned = append(cloned, *cloneBridgeInstance(instance))
+ }
+ return cloned
+}
+
+func sortBridgeInstances(instances []BridgeInstance) {
+ slices.SortFunc(instances, func(left BridgeInstance, right BridgeInstance) int {
+ if byDisplay := strings.Compare(left.DisplayName, right.DisplayName); byDisplay != 0 {
+ return byDisplay
+ }
+ if byCreated := left.CreatedAt.Compare(right.CreatedAt); byCreated != 0 {
+ return byCreated
+ }
+ return strings.Compare(left.ID, right.ID)
+ })
+}
diff --git a/internal/bridges/resource_test.go b/internal/bridges/resource_test.go
new file mode 100644
index 000000000..4ef15cfe2
--- /dev/null
+++ b/internal/bridges/resource_test.go
@@ -0,0 +1,792 @@
+package bridges_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestBridgeInstanceResourceCodecRejectsInvalidPayloads(t *testing.T) {
+ t.Parallel()
+
+ codec, err := bridgepkg.NewBridgeInstanceResourceCodec(nil)
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+
+ tests := []struct {
+ name string
+ scope resources.ResourceScope
+ raw []byte
+ wantIs error
+ wantError string
+ }{
+ {
+ name: "invalid scope binding",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-1"},
+ wantIs: resources.ErrInvalidScopeBinding,
+ wantError: `bridge.scope "global" does not match resource scope "workspace"`,
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"open",
+ "routing_policy":{"include_peer":true}
+ }`),
+ },
+ {
+ name: "malformed provider config",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ wantError: "bridge instance provider config must be a JSON object or null",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"open",
+ "routing_policy":{"include_peer":true},
+ "provider_config":["not","object"]
+ }`),
+ },
+ {
+ name: "invalid dm policy",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ wantError: `unsupported dm policy "invite-everyone"`,
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"invite-everyone",
+ "routing_policy":{"include_peer":true}
+ }`),
+ },
+ {
+ name: "illegal delivery defaults",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ wantError: "thread_id requires peer_id or group_id",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"open",
+ "routing_policy":{"include_peer":true},
+ "delivery_defaults":{"thread_id":"thread-without-peer"}
+ }`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := codec.DecodeAndValidate(testutil.Context(t), tt.scope, tt.raw)
+ if err == nil {
+ t.Fatalf("DecodeAndValidate() error = nil, want validation failure")
+ }
+ if tt.wantIs != nil && !errors.Is(err, tt.wantIs) {
+ t.Fatalf("DecodeAndValidate() error = %v, want errors.Is(..., %v)", err, tt.wantIs)
+ }
+ if tt.wantError != "" && !strings.Contains(err.Error(), tt.wantError) {
+ t.Fatalf("DecodeAndValidate() error = %v, want substring %q", err, tt.wantError)
+ }
+ })
+ }
+}
+
+func TestBridgeInstanceResourceCodecEnforcesProviderManifestMetadata(t *testing.T) {
+ t.Parallel()
+
+ lookup := func(_ context.Context, extensionName string) (bridgepkg.BridgeProvider, bool, error) {
+ if strings.TrimSpace(extensionName) != "ext-telegram" {
+ return bridgepkg.BridgeProvider{}, false, nil
+ }
+ return bridgepkg.BridgeProvider{
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "Telegram",
+ SecretSlots: []bridgepkg.BridgeSecretSlot{
+ {Name: "bot_token", Required: true},
+ {Name: "signing_secret"},
+ },
+ ConfigSchema: &bridgepkg.BridgeProviderConfigSchema{
+ Schema: "telegram.bot",
+ Version: "v1",
+ },
+ }, true, nil
+ }
+ codec, err := bridgepkg.NewBridgeInstanceResourceCodec(lookup)
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ spec, err := codec.DecodeAndValidate(scopeContext(t), scope, []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"pairing",
+ "routing_policy":{"include_peer":true},
+ "provider_config":{"tenant":"acme"},
+ "delivery_defaults":{"peer_id":"peer-1","mode":"reply"}
+ }`))
+ if err != nil {
+ t.Fatalf("DecodeAndValidate(valid) error = %v", err)
+ }
+ if got, want := len(spec.SecretSlots), 2; got != want {
+ t.Fatalf("len(spec.SecretSlots) = %d, want %d", got, want)
+ }
+ if spec.ConfigSchema == nil || spec.ConfigSchema.Schema != "telegram.bot" ||
+ spec.ConfigSchema.Version != "v1" {
+ t.Fatalf("spec.ConfigSchema = %#v, want manifest schema", spec.ConfigSchema)
+ }
+ if got, want := string(spec.ProviderConfig), `{"tenant":"acme"}`; got != want {
+ t.Fatalf("spec.ProviderConfig = %s, want %s", got, want)
+ }
+
+ for _, tc := range []struct {
+ name string
+ raw []byte
+ wantError string
+ }{
+ {
+ name: "platform mismatch",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"slack",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"pairing",
+ "routing_policy":{"include_peer":true}
+ }`),
+ wantError: `bridge provider "ext-telegram" platform "telegram" does not match resource platform "slack"`,
+ },
+ {
+ name: "secret slot mismatch",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"pairing",
+ "routing_policy":{"include_peer":true},
+ "secret_slots":[{"name":"wrong"}]
+ }`),
+ wantError: "secret_slots metadata does not match manifest",
+ },
+ {
+ name: "config schema mismatch",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"ext-telegram",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"pairing",
+ "routing_policy":{"include_peer":true},
+ "config_schema":{"schema":"different","version":"v1"}
+ }`),
+ wantError: "config_schema metadata does not match manifest",
+ },
+ {
+ name: "unknown provider",
+ raw: []byte(`{
+ "scope":"global",
+ "platform":"telegram",
+ "extension_name":"missing-provider",
+ "display_name":"Support",
+ "enabled":true,
+ "dm_policy":"pairing",
+ "routing_policy":{"include_peer":true}
+ }`),
+ wantError: `bridge provider "missing-provider" is not installed`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ _, err := codec.DecodeAndValidate(scopeContext(t), scope, tc.raw)
+ if err == nil {
+ t.Fatalf("DecodeAndValidate(%s) error = nil, want validation failure", tc.name)
+ }
+ if !strings.Contains(err.Error(), tc.wantError) {
+ t.Fatalf("DecodeAndValidate(%s) error = %v, want substring %q", tc.name, err, tc.wantError)
+ }
+ })
+ }
+}
+
+func TestBridgeResourceBuildComputesDeltaWithoutApplyingSideEffects(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ store := &projectionStore{
+ instances: []bridgepkg.BridgeInstance{{
+ ID: "brg-existing",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "Existing",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now.Add(-time.Hour),
+ }},
+ }
+
+ records := []resources.Record[bridgepkg.BridgeInstanceSpec]{{
+ ID: "brg-existing",
+ Version: 7,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: resourceSpec("Updated", true),
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now,
+ }}
+ plan, err := bridgepkg.BuildResourceState(testutil.Context(t), store, records, func() time.Time { return now })
+ if err != nil {
+ t.Fatalf("BuildResourceState() error = %v", err)
+ }
+ if got, want := plan.Revision(), int64(7); got != want {
+ t.Fatalf("plan.Revision() = %d, want %d", got, want)
+ }
+ if got, want := plan.OperationCount(), 1; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+ if got := len(store.replacements); got != 0 {
+ t.Fatalf("BuildResourceState applied %d replacements, want 0", got)
+ }
+ next := plan.NextInstances()
+ if len(next) != 1 || next[0].DisplayName != "Updated" || next[0].Status != bridgepkg.BridgeStatusReady {
+ t.Fatalf("plan.NextInstances() = %#v, want updated desired fields with preserved status", next)
+ }
+}
+
+func TestBridgeResourceProjectionRemovesLegacyRowsWhenSnapshotIsEmpty(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ store := &projectionStore{
+ instances: []bridgepkg.BridgeInstance{{
+ ID: "brg-legacy",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "Legacy",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }},
+ }
+
+ plan, err := bridgepkg.BuildResourceState(testutil.Context(t), store, nil, func() time.Time { return now })
+ if err != nil {
+ t.Fatalf("BuildResourceState(empty) error = %v", err)
+ }
+ if got, want := plan.OperationCount(), 1; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+ if err := bridgepkg.ApplyResourceState(testutil.Context(t), store, plan); err != nil {
+ t.Fatalf("ApplyResourceState(empty) error = %v", err)
+ }
+ if got := len(store.instances); got != 0 {
+ t.Fatalf("len(store.instances) = %d, want legacy rows removed", got)
+ }
+}
+
+func TestBridgeResourceApplyReturnsReplaceFailure(t *testing.T) {
+ t.Parallel()
+
+ wantErr := errors.New("replace failed")
+ store := &projectionStore{}
+ plan, err := bridgepkg.BuildResourceState(
+ testutil.Context(t),
+ store,
+ []resources.Record[bridgepkg.BridgeInstanceSpec]{{
+ ID: "brg-fail",
+ Version: 1,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: resourceSpec("Failing", true),
+ CreatedAt: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC),
+ }},
+ time.Now,
+ )
+ if err != nil {
+ t.Fatalf("BuildResourceState() error = %v", err)
+ }
+ store.replaceErr = wantErr
+ err = bridgepkg.ApplyResourceState(testutil.Context(t), store, plan)
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("ApplyResourceState() error = %v, want %v", err, wantErr)
+ }
+}
+
+func TestBridgeResourceProjectionPlanAccessorsAndRollback(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ store := &projectionStore{
+ instances: []bridgepkg.BridgeInstance{{
+ ID: "brg-accessor",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-old",
+ DisplayName: "Before",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusDegraded,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{
+ "tenant":"acme"
+ }`),
+ Degradation: &bridgepkg.BridgeDegradation{
+ Reason: bridgepkg.BridgeDegradationReasonAuthFailed,
+ Message: "waiting for adapter refresh",
+ },
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now.Add(-time.Minute),
+ }},
+ }
+
+ spec := resourceSpec("After", true)
+ spec.ExtensionName = "ext-new"
+ plan, err := bridgepkg.BuildResourceState(
+ testutil.Context(t),
+ store,
+ []resources.Record[bridgepkg.BridgeInstanceSpec]{{
+ ID: "brg-accessor",
+ Version: 17,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: spec,
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now,
+ }},
+ func() time.Time { return now },
+ )
+ if err != nil {
+ t.Fatalf("BuildResourceState() error = %v", err)
+ }
+
+ if got, want := plan.Kind(), bridgepkg.BridgeInstanceResourceKind; got != want {
+ t.Fatalf("plan.Kind() = %q, want %q", got, want)
+ }
+ if got, want := plan.ChangedExtensions(), []string{
+ "ext-new",
+ "ext-old",
+ }; strings.Join(
+ got,
+ ",",
+ ) != strings.Join(
+ want,
+ ",",
+ ) {
+ t.Fatalf("plan.ChangedExtensions() = %#v, want %#v", got, want)
+ }
+ previous := plan.PreviousInstances()
+ if len(previous) != 1 || previous[0].Degradation == nil {
+ t.Fatalf("plan.PreviousInstances() = %#v, want preserved degradation", previous)
+ }
+
+ rollback := plan.RollbackPlan()
+ if rollback == nil {
+ t.Fatalf("plan.RollbackPlan() = nil, want rollback plan")
+ }
+ next := rollback.NextInstances()
+ if len(next) != 1 || next[0].DisplayName != "Before" || next[0].ExtensionName != "ext-old" {
+ t.Fatalf("rollback.NextInstances() = %#v, want previous bridge state", next)
+ }
+}
+
+func TestBridgeResourceProjectionIgnoresSemanticallyEquivalentJSON(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ store := &projectionStore{
+ instances: []bridgepkg.BridgeInstance{{
+ ID: "brg-json",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "JSON Bridge",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme","features":{"beta":true}}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-1","mode":"reply"}`),
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now.Add(-time.Minute),
+ }},
+ }
+
+ spec := resourceSpec("JSON Bridge", true)
+ spec.ProviderConfig = []byte("{\n \"features\": {\"beta\": true},\n \"tenant\": \"acme\"\n}")
+ spec.DeliveryDefaults = []byte(`{"mode":"reply","peer_id":"peer-1"}`)
+ plan, err := bridgepkg.BuildResourceState(
+ testutil.Context(t),
+ store,
+ []resources.Record[bridgepkg.BridgeInstanceSpec]{{
+ ID: "brg-json",
+ Version: 9,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: spec,
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now,
+ }},
+ func() time.Time { return now },
+ )
+ if err != nil {
+ t.Fatalf("BuildResourceState() error = %v", err)
+ }
+ if got, want := plan.OperationCount(), 0; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+ if got := plan.ChangedExtensions(); len(got) != 0 {
+ t.Fatalf("plan.ChangedExtensions() = %#v, want no changed extensions", got)
+ }
+}
+
+func TestBridgeInstanceSpecFromCreateRequestBindsWorkspaceScope(t *testing.T) {
+ t.Parallel()
+
+ request := bridgepkg.CreateInstanceRequest{
+ Scope: bridgepkg.ScopeWorkspace,
+ WorkspaceID: "ws-alpha",
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "Workspace Telegram",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme"}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-1","mode":"reply"}`),
+ }
+
+ id, spec, err := bridgepkg.BridgeInstanceSpecFromCreateRequest(request, func() time.Time {
+ return time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ })
+ if err != nil {
+ t.Fatalf("BridgeInstanceSpecFromCreateRequest() error = %v", err)
+ }
+ if strings.TrimSpace(id) == "" {
+ t.Fatalf("BridgeInstanceSpecFromCreateRequest() id is empty")
+ }
+ scope := bridgepkg.ResourceScopeForBridge(spec.Scope, spec.WorkspaceID)
+ if got, want := scope.Kind, resources.ResourceScopeKindWorkspace; got != want {
+ t.Fatalf("scope.Kind = %q, want %q", got, want)
+ }
+ if got, want := scope.ID, "ws-alpha"; got != want {
+ t.Fatalf("scope.ID = %q, want %q", got, want)
+ }
+ if got, want := string(spec.ProviderConfig), `{"tenant":"acme"}`; got != want {
+ t.Fatalf("spec.ProviderConfig = %s, want %s", got, want)
+ }
+}
+
+func TestManagedResourceSyncerReconcilesCanonicalBridgeResources(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ keep := managedBridgeInstance("brg-keep", "Keep")
+ stale := managedBridgeInstance("brg-stale", "Stale")
+ store := newManagedResourceStore(
+ managedBridgeResourceRecord(keep.ID, 4, keep),
+ managedBridgeResourceRecord(stale.ID, 5, stale),
+ )
+ triggered := 0
+ service := bridgepkg.NewManagedResourceSyncer(
+ store,
+ resources.MutationActor{Kind: resources.MutationActorKindDaemon},
+ func(_ context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ triggered++
+ if kind != bridgepkg.BridgeInstanceResourceKind {
+ t.Fatalf("trigger kind = %q, want %q", kind, bridgepkg.BridgeInstanceResourceKind)
+ }
+ if reason != resources.ReconcileReasonWrite {
+ t.Fatalf("trigger reason = %q, want %q", reason, resources.ReconcileReasonWrite)
+ }
+ return nil
+ },
+ bridgepkg.WithManagedResourceSyncNow(func() time.Time { return now }),
+ )
+
+ stats, err := service.SyncManagedInstances(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceSourcePackage,
+ []bridgepkg.BridgeInstance{
+ keep,
+ managedBridgeInstance("brg-new", "New"),
+ },
+ )
+ if err != nil {
+ t.Fatalf("SyncManagedInstances() error = %v", err)
+ }
+ if stats.InstancesSynced != 2 || stats.InstancesRemoved != 1 || !stats.SyncedAt.Equal(now) {
+ t.Fatalf("stats = %#v, want 2 synced, 1 removed at %s", stats, now)
+ }
+ if got, want := len(store.puts), 1; got != want {
+ t.Fatalf("len(store.puts) = %d, want %d", got, want)
+ }
+ if got, want := store.puts[0].ID, "brg-new"; got != want {
+ t.Fatalf("store.puts[0].ID = %q, want %q", got, want)
+ }
+ if got, want := len(store.deletes), 1; got != want {
+ t.Fatalf("len(store.deletes) = %d, want %d", got, want)
+ }
+ if got, want := store.deletes[0], "brg-stale"; got != want {
+ t.Fatalf("store.deletes[0] = %q, want %q", got, want)
+ }
+ if triggered != 1 {
+ t.Fatalf("triggered = %d, want 1", triggered)
+ }
+
+ saved := store.records["brg-new"]
+ if saved.Source.ID != "bridge.package" || saved.Spec.Source != bridgepkg.BridgeInstanceSourcePackage {
+ t.Fatalf("new record source = %#v spec source = %q, want package source", saved.Source, saved.Spec.Source)
+ }
+}
+
+func TestManagedResourceSyncerReportsInputAndTriggerFailures(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t)
+ var nilService *bridgepkg.ManagedResourceSyncService
+ if _, err := nilService.SyncManagedInstances(ctx, bridgepkg.BridgeInstanceSourcePackage, nil); err == nil {
+ t.Fatalf("nil service SyncManagedInstances() error = nil, want failure")
+ }
+
+ noStore := bridgepkg.NewManagedResourceSyncer(nil, resources.MutationActor{}, nil)
+ if _, err := noStore.SyncManagedInstances(ctx, bridgepkg.BridgeInstanceSourcePackage, nil); err == nil {
+ t.Fatalf("missing store SyncManagedInstances() error = nil, want failure")
+ }
+
+ duplicateStore := newManagedResourceStore()
+ duplicateService := bridgepkg.NewManagedResourceSyncer(duplicateStore, resources.MutationActor{}, nil)
+ duplicate := managedBridgeInstance("brg-duplicate", "Duplicate")
+ if _, err := duplicateService.SyncManagedInstances(
+ ctx,
+ bridgepkg.BridgeInstanceSourcePackage,
+ []bridgepkg.BridgeInstance{
+ duplicate,
+ duplicate,
+ },
+ ); err == nil {
+ t.Fatalf("duplicate SyncManagedInstances() error = nil, want failure")
+ }
+
+ wantErr := errors.New("reconcile failed")
+ triggerService := bridgepkg.NewManagedResourceSyncer(
+ newManagedResourceStore(),
+ resources.MutationActor{},
+ func(context.Context, resources.ResourceKind, resources.ReconcileReason) error {
+ return wantErr
+ },
+ )
+ if _, err := triggerService.SyncManagedInstances(
+ ctx,
+ bridgepkg.BridgeInstanceSourcePackage,
+ []bridgepkg.BridgeInstance{
+ managedBridgeInstance("brg-trigger", "Trigger"),
+ },
+ ); !errors.Is(
+ err,
+ wantErr,
+ ) {
+ t.Fatalf("trigger SyncManagedInstances() error = %v, want %v", err, wantErr)
+ }
+}
+
+func scopeContext(t *testing.T) context.Context {
+ t.Helper()
+ return testutil.Context(t)
+}
+
+func resourceSpec(displayName string, enabled bool) bridgepkg.BridgeInstanceSpec {
+ return bridgepkg.BridgeInstanceSpec{
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: displayName,
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: enabled,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme"}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-1","mode":"reply"}`),
+ }
+}
+
+type projectionStore struct {
+ instances []bridgepkg.BridgeInstance
+ replacements [][]bridgepkg.BridgeInstance
+ replaceErr error
+}
+
+func (s *projectionStore) ListBridgeInstances(context.Context) ([]bridgepkg.BridgeInstance, error) {
+ instances := make([]bridgepkg.BridgeInstance, 0, len(s.instances))
+ for _, instance := range s.instances {
+ instances = append(instances, cloneBridgeInstanceForTest(instance))
+ }
+ return instances, nil
+}
+
+func (s *projectionStore) ReplaceBridgeInstances(_ context.Context, instances []bridgepkg.BridgeInstance) error {
+ if s.replaceErr != nil {
+ return s.replaceErr
+ }
+ next := make([]bridgepkg.BridgeInstance, 0, len(instances))
+ for _, instance := range instances {
+ next = append(next, cloneBridgeInstanceForTest(instance))
+ }
+ s.replacements = append(s.replacements, next)
+ s.instances = next
+ return nil
+}
+
+type managedResourceStore struct {
+ records map[string]resources.Record[bridgepkg.BridgeInstanceSpec]
+ puts []resources.Draft[bridgepkg.BridgeInstanceSpec]
+ deletes []string
+ listErr error
+ putErr error
+ deleteErr error
+}
+
+func newManagedResourceStore(
+ records ...resources.Record[bridgepkg.BridgeInstanceSpec],
+) *managedResourceStore {
+ store := &managedResourceStore{
+ records: make(map[string]resources.Record[bridgepkg.BridgeInstanceSpec], len(records)),
+ }
+ for _, record := range records {
+ store.records[record.ID] = record
+ }
+ return store
+}
+
+func (s *managedResourceStore) Put(
+ _ context.Context,
+ actor resources.MutationActor,
+ draft resources.Draft[bridgepkg.BridgeInstanceSpec],
+) (resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ if s.putErr != nil {
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{}, s.putErr
+ }
+ version := draft.ExpectedVersion + 1
+ record := resources.Record[bridgepkg.BridgeInstanceSpec]{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ ID: draft.ID,
+ Version: version,
+ Scope: draft.Scope,
+ Source: actor.Source,
+ Spec: draft.Spec,
+ }
+ s.records[draft.ID] = record
+ s.puts = append(s.puts, draft)
+ return record, nil
+}
+
+func (s *managedResourceStore) Delete(
+ _ context.Context,
+ _ resources.MutationActor,
+ id string,
+ _ int64,
+) error {
+ if s.deleteErr != nil {
+ return s.deleteErr
+ }
+ delete(s.records, id)
+ s.deletes = append(s.deletes, id)
+ return nil
+}
+
+func (s *managedResourceStore) Get(
+ context.Context,
+ resources.MutationActor,
+ string,
+) (resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{}, errors.New("unexpected Get call")
+}
+
+func (s *managedResourceStore) List(
+ _ context.Context,
+ _ resources.MutationActor,
+ filter resources.ResourceFilter,
+) ([]resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ if s.listErr != nil {
+ return nil, s.listErr
+ }
+ records := make([]resources.Record[bridgepkg.BridgeInstanceSpec], 0, len(s.records))
+ for _, record := range s.records {
+ if filter.Kind != "" && filter.Kind.Normalize() != record.Kind.Normalize() {
+ continue
+ }
+ if filter.Source != nil && *filter.Source != record.Source {
+ continue
+ }
+ records = append(records, record)
+ }
+ return records, nil
+}
+
+func managedBridgeInstance(id string, displayName string) bridgepkg.BridgeInstance {
+ return bridgepkg.BridgeInstance{
+ ID: id,
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: displayName,
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme"}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-1","mode":"reply"}`),
+ }
+}
+
+func managedBridgeResourceRecord(
+ id string,
+ version int64,
+ instance bridgepkg.BridgeInstance,
+) resources.Record[bridgepkg.BridgeInstanceSpec] {
+ instance.Source = bridgepkg.BridgeInstanceSourcePackage
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ ID: id,
+ Version: version,
+ Scope: bridgepkg.ResourceScopeForBridge(instance.Scope, instance.WorkspaceID),
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "bridge.package",
+ },
+ Spec: bridgepkg.BridgeInstanceSpecFromInstance(instance),
+ }
+}
diff --git a/internal/bridgesdk/test_helpers_test.go b/internal/bridgesdk/test_helpers_test.go
index 83db7f8b3..892d38d0c 100644
--- a/internal/bridgesdk/test_helpers_test.go
+++ b/internal/bridgesdk/test_helpers_test.go
@@ -50,6 +50,7 @@ func testInitializeRequest() subprocess.InitializeRequest {
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "test",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "telegram-adapter",
Version: "1.0.0",
diff --git a/internal/bundles/resource.go b/internal/bundles/resource.go
new file mode 100644
index 000000000..9afee38bd
--- /dev/null
+++ b/internal/bundles/resource.go
@@ -0,0 +1,215 @@
+package bundles
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+)
+
+const (
+ // BundleResourceKind is the canonical desired-state kind for extension bundles.
+ BundleResourceKind resources.ResourceKind = "bundle"
+ // BundleActivationResourceKind is the canonical desired-state kind for active bundle profiles.
+ BundleActivationResourceKind resources.ResourceKind = "bundle.activation"
+
+ BundleActivationOwnerKind resources.ResourceOwnerKind = "bundle.activation"
+
+ bundleResourceMaxBytes = 512 << 10
+ bundleActivationResourceMaxBytes = 64 << 10
+)
+
+// BundleResourceSpec is the canonical desired-state payload for bundle catalog records.
+type BundleResourceSpec struct {
+ ExtensionName string `json:"extension_name"`
+ Bundle extensionpkg.BundleSpec `json:"bundle"`
+ OwnerBridgePlatform string `json:"owner_bridge_platform,omitempty"`
+ OwnerProvidesBridgeAdapter bool `json:"owner_provides_bridge_adapter,omitempty"`
+}
+
+// ActivationResourceSpec is the canonical desired-state payload for bundle activation records.
+type ActivationResourceSpec struct {
+ ExtensionName string `json:"extension_name"`
+ BundleName string `json:"bundle_name"`
+ ProfileName string `json:"profile_name"`
+ SpecContentHash string `json:"spec_content_hash,omitempty"`
+ BindPrimaryChannelAsDefault bool `json:"bind_primary_channel_default"`
+}
+
+// NewBundleResourceCodec builds the typed codec for bundle records.
+func NewBundleResourceCodec() (resources.KindCodec[BundleResourceSpec], error) {
+ return resources.NewJSONCodec(BundleResourceKind, bundleResourceMaxBytes, validateBundleResourceSpec)
+}
+
+// NewActivationResourceCodec builds the typed codec for bundle.activation records.
+func NewActivationResourceCodec() (resources.KindCodec[ActivationResourceSpec], error) {
+ return resources.NewJSONCodec(
+ BundleActivationResourceKind,
+ bundleActivationResourceMaxBytes,
+ validateActivationResourceSpec,
+ )
+}
+
+// BundleResourceID returns the stable canonical resource ID for one extension bundle.
+func BundleResourceID(extensionName string, bundleName string) string {
+ return stableID("bun", extensionName, bundleName)
+}
+
+// ActivationResourceID returns the stable canonical resource ID for one bundle profile activation.
+func ActivationResourceID(
+ extensionName string,
+ bundleName string,
+ profileName string,
+ scope Scope,
+ workspaceID string,
+) string {
+ return stableID(
+ "act",
+ extensionName,
+ bundleName,
+ profileName,
+ string(scope.Normalize()),
+ strings.TrimSpace(workspaceID),
+ )
+}
+
+func validateBundleResourceSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec BundleResourceSpec,
+) (BundleResourceSpec, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return BundleResourceSpec{}, err
+ }
+ next := normalizeBundleResourceSpec(spec)
+ if strings.TrimSpace(next.ExtensionName) == "" {
+ return BundleResourceSpec{}, errors.New("bundles: resource extension_name is required")
+ }
+ manifest := &extensionpkg.Manifest{
+ Name: strings.TrimSpace(next.ExtensionName),
+ Bridge: extensionpkg.BridgeConfig{
+ Platform: strings.TrimSpace(next.OwnerBridgePlatform),
+ },
+ }
+ if next.OwnerProvidesBridgeAdapter || strings.TrimSpace(next.OwnerBridgePlatform) != "" {
+ manifest.Capabilities.Provides = []string{"bridge.adapter"}
+ next.OwnerProvidesBridgeAdapter = true
+ }
+ if err := next.Bundle.Validate(manifest); err != nil {
+ return BundleResourceSpec{}, fmt.Errorf("bundles: validate bundle resource: %w", err)
+ }
+ return next, nil
+}
+
+func validateActivationResourceSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec ActivationResourceSpec,
+) (ActivationResourceSpec, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return ActivationResourceSpec{}, err
+ }
+ next := normalizeActivationResourceSpec(spec)
+ if strings.TrimSpace(next.ExtensionName) == "" {
+ return ActivationResourceSpec{}, errors.New("bundles: activation extension_name is required")
+ }
+ if strings.TrimSpace(next.BundleName) == "" {
+ return ActivationResourceSpec{}, errors.New("bundles: activation bundle_name is required")
+ }
+ if strings.TrimSpace(next.ProfileName) == "" {
+ return ActivationResourceSpec{}, errors.New("bundles: activation profile_name is required")
+ }
+ return next, nil
+}
+
+func normalizeBundleResourceSpec(spec BundleResourceSpec) BundleResourceSpec {
+ next := spec
+ next.ExtensionName = strings.TrimSpace(next.ExtensionName)
+ next.OwnerBridgePlatform = strings.TrimSpace(next.OwnerBridgePlatform)
+ next.Bundle = cloneBundleSpec(next.Bundle)
+ next.Bundle.Name = strings.TrimSpace(next.Bundle.Name)
+ next.Bundle.Description = strings.TrimSpace(next.Bundle.Description)
+ return next
+}
+
+func normalizeActivationResourceSpec(spec ActivationResourceSpec) ActivationResourceSpec {
+ return ActivationResourceSpec{
+ ExtensionName: strings.TrimSpace(spec.ExtensionName),
+ BundleName: strings.TrimSpace(spec.BundleName),
+ ProfileName: strings.TrimSpace(spec.ProfileName),
+ SpecContentHash: strings.TrimSpace(spec.SpecContentHash),
+ BindPrimaryChannelAsDefault: spec.BindPrimaryChannelAsDefault,
+ }
+}
+
+func activationResourceSpecFromActivation(activation Activation) ActivationResourceSpec {
+ return ActivationResourceSpec{
+ ExtensionName: strings.TrimSpace(activation.ExtensionName),
+ BundleName: strings.TrimSpace(activation.BundleName),
+ ProfileName: strings.TrimSpace(activation.ProfileName),
+ SpecContentHash: strings.TrimSpace(activation.SpecContentHash),
+ BindPrimaryChannelAsDefault: activation.BindPrimaryChannelAsDefault,
+ }
+}
+
+func activationFromResourceRecord(record resources.Record[ActivationResourceSpec]) Activation {
+ scope := ScopeGlobal
+ workspaceID := ""
+ if record.Scope.Kind == resources.ResourceScopeKindWorkspace {
+ scope = ScopeWorkspace
+ workspaceID = strings.TrimSpace(record.Scope.ID)
+ }
+ return Activation{
+ ID: strings.TrimSpace(record.ID),
+ ExtensionName: strings.TrimSpace(record.Spec.ExtensionName),
+ BundleName: strings.TrimSpace(record.Spec.BundleName),
+ ProfileName: strings.TrimSpace(record.Spec.ProfileName),
+ Scope: scope,
+ WorkspaceID: workspaceID,
+ SpecContentHash: strings.TrimSpace(record.Spec.SpecContentHash),
+ BindPrimaryChannelAsDefault: record.Spec.BindPrimaryChannelAsDefault,
+ CreatedAt: record.CreatedAt,
+ UpdatedAt: record.UpdatedAt,
+ }
+}
+
+func resourceScopeForActivation(activation Activation) resources.ResourceScope {
+ if activation.Scope.Normalize() == ScopeWorkspace {
+ return resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: strings.TrimSpace(activation.WorkspaceID),
+ }
+ }
+ return resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+}
+
+func ownerForActivation(id string) resources.ResourceOwner {
+ return resources.ResourceOwner{
+ Kind: BundleActivationOwnerKind,
+ ID: strings.TrimSpace(id),
+ }
+}
+
+func activationResourceActor(base resources.MutationActor, activationID string) resources.MutationActor {
+ trimmedID := strings.TrimSpace(activationID)
+ actor := base
+ if actor.Kind == "" {
+ actor.Kind = resources.MutationActorKindDaemon
+ }
+ if actor.MaxScope.Kind == "" {
+ actor.MaxScope = resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ }
+ actor.ID = "bundle.activation." + trimmedID
+ actor.Owner = ownerForActivation(trimmedID)
+ actor.Source = resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: actor.ID,
+ }
+ return actor
+}
diff --git a/internal/bundles/resource_integration_test.go b/internal/bundles/resource_integration_test.go
new file mode 100644
index 000000000..1c406ffec
--- /dev/null
+++ b/internal/bundles/resource_integration_test.go
@@ -0,0 +1,434 @@
+//go:build integration
+
+package bundles
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
+ storepkg "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+type bundleResourceIntegrationHarness struct {
+ ctx context.Context
+ db *sql.DB
+ kernel *resources.Kernel
+ codecs *resources.CodecRegistry
+ actor resources.MutationActor
+ resourceStore *ResourceStore
+ service *Service
+ bundles resources.Store[BundleResourceSpec]
+ activations resources.Store[ActivationResourceSpec]
+ jobs resources.Store[automationpkg.Job]
+ triggers resources.Store[automationpkg.Trigger]
+ bridges resources.Store[bridgepkg.BridgeInstanceSpec]
+ triggeredKinds []resources.ResourceKind
+}
+
+func TestBundleResourceIntegrationActivationFanoutWritesCanonicalOwnedRecords(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceIntegrationHarness(t)
+ h.putMarketingBundle(t)
+
+ preview, err := h.service.Activate(h.ctx, ActivateRequest{
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ })
+ if err != nil {
+ t.Fatalf("Activate() error = %v", err)
+ }
+
+ owner := ownerForActivation(preview.Activation.ID)
+ jobs := h.listOwnedJobs(t, owner)
+ if got, want := len(jobs), 1; got != want {
+ t.Fatalf("len(owned jobs) = %d, want %d", got, want)
+ }
+ if jobs[0].Owner != owner {
+ t.Fatalf("job owner = %#v, want %#v", jobs[0].Owner, owner)
+ }
+ triggers := h.listOwnedTriggers(t, owner)
+ if got, want := len(triggers), 1; got != want {
+ t.Fatalf("len(owned triggers) = %d, want %d", got, want)
+ }
+ bridges := h.listOwnedBridges(t, owner)
+ if got, want := len(bridges), 1; got != want {
+ t.Fatalf("len(owned bridges) = %d, want %d", got, want)
+ }
+ for _, kind := range []resources.ResourceKind{
+ automationpkg.JobResourceKind,
+ automationpkg.TriggerResourceKind,
+ bridgepkg.BridgeInstanceResourceKind,
+ } {
+ if !slices.Contains(h.triggeredKinds, kind) {
+ t.Fatalf("triggered kinds = %#v, want %q", h.triggeredKinds, kind)
+ }
+ }
+}
+
+func TestBundleResourceIntegrationCleanupIsActivationScoped(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceIntegrationHarness(t)
+ removeJob := integrationJob("job-remove", "remove")
+ keepJob := integrationJob("job-keep", "keep")
+ unownedJob := integrationJob("job-unowned", "unowned")
+
+ h.putJob(t, activationResourceActor(h.actor, "act-remove"), removeJob)
+ h.putJob(t, activationResourceActor(h.actor, "act-keep"), keepJob)
+ h.putJob(t, h.actor, unownedJob)
+
+ err := h.resourceStore.ApplyBundleActivationResources(h.ctx, BundleActivationResourcePlan{
+ activeActivationIDs: map[string]struct{}{"act-keep": {}},
+ desiredJobs: []automationpkg.Job{keepJob},
+ jobOwners: map[string]string{keepJob.ID: "act-keep"},
+ })
+ if err != nil {
+ t.Fatalf("ApplyBundleActivationResources() error = %v", err)
+ }
+
+ if _, err := h.jobs.Get(h.ctx, h.actor, removeJob.ID); !errors.Is(err, resources.ErrNotFound) {
+ t.Fatalf("Get(removeJob) error = %v, want ErrNotFound", err)
+ }
+ if _, err := h.jobs.Get(h.ctx, h.actor, keepJob.ID); err != nil {
+ t.Fatalf("Get(keepJob) error = %v", err)
+ }
+ if _, err := h.jobs.Get(h.ctx, h.actor, unownedJob.ID); err != nil {
+ t.Fatalf("Get(unownedJob) error = %v", err)
+ }
+}
+
+func TestBundleResourceIntegrationBootRebuildUsesResourcesWithoutInventoryTable(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceIntegrationHarness(t)
+ h.putMarketingBundle(t)
+ activation := Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeGlobal, ""),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ }
+ if err := h.resourceStore.CreateBundleActivation(h.ctx, activation); err != nil {
+ t.Fatalf("CreateBundleActivation() error = %v", err)
+ }
+
+ registration, err := resources.NewBundleActivationProjectorRegistration[
+ ActivationResourceSpec,
+ BundleResourceSpec,
+ ](h.codecs, h.service)
+ if err != nil {
+ t.Fatalf("NewBundleActivationProjectorRegistration() error = %v", err)
+ }
+ driver, err := resources.NewReconcileDriver(h.kernel, h.actor, []resources.ProjectorRegistration{registration})
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := driver.Close(h.ctx); err != nil {
+ t.Fatalf("driver.Close() error = %v", err)
+ }
+ })
+
+ if err := driver.RunBoot(h.ctx); err != nil {
+ t.Fatalf("RunBoot() error = %v", err)
+ }
+ owner := ownerForActivation(activation.ID)
+ if got, want := len(h.listOwnedJobs(t, owner)), 1; got != want {
+ t.Fatalf("len(boot rebuilt owned jobs) = %d, want %d", got, want)
+ }
+ assertNoLegacyBundleActivationTable(t, h.db)
+}
+
+func newBundleResourceIntegrationHarness(t *testing.T) *bundleResourceIntegrationHarness {
+ t.Helper()
+
+ ctx := testutil.Context(t)
+ db, err := storepkg.OpenSQLiteDatabase(
+ ctx,
+ filepath.Join(t.TempDir(), storepkg.GlobalDatabaseName),
+ func(ctx context.Context, db *sql.DB) error {
+ return storepkg.EnsureSchema(ctx, db, resources.SchemaStatements())
+ },
+ )
+ if err != nil {
+ t.Fatalf("OpenSQLiteDatabase() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := db.Close(); err != nil {
+ t.Fatalf("db.Close() error = %v", err)
+ }
+ })
+ kernel, err := resources.NewKernel(db, resources.WithNow(func() time.Time {
+ return time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
+ }))
+ if err != nil {
+ t.Fatalf("NewKernel() error = %v", err)
+ }
+ actor := bundleIntegrationActor()
+ codecs := resources.NewCodecRegistry()
+ bundleCodec, err := NewBundleResourceCodec()
+ if err != nil {
+ t.Fatalf("NewBundleResourceCodec() error = %v", err)
+ }
+ activationCodec, err := NewActivationResourceCodec()
+ if err != nil {
+ t.Fatalf("NewActivationResourceCodec() error = %v", err)
+ }
+ jobCodec, err := automationpkg.NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("NewJobResourceCodec() error = %v", err)
+ }
+ triggerCodec, err := automationpkg.NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewTriggerResourceCodec() error = %v", err)
+ }
+ bridgeCodec, err := bridgepkg.NewBridgeInstanceResourceCodec(marketingBridgeProviderLookup)
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+ for _, register := range []func(*resources.CodecRegistry) error{
+ func(registry *resources.CodecRegistry) error { return resources.RegisterCodec(registry, bundleCodec) },
+ func(registry *resources.CodecRegistry) error {
+ return resources.RegisterCodec(registry, activationCodec)
+ },
+ func(registry *resources.CodecRegistry) error { return resources.RegisterCodec(registry, jobCodec) },
+ func(registry *resources.CodecRegistry) error { return resources.RegisterCodec(registry, triggerCodec) },
+ func(registry *resources.CodecRegistry) error { return resources.RegisterCodec(registry, bridgeCodec) },
+ } {
+ if err := register(codecs); err != nil {
+ t.Fatalf("RegisterCodec() error = %v", err)
+ }
+ }
+
+ bundleStore := mustNewTypedStore(t, kernel, bundleCodec)
+ activationStore := mustNewTypedStore(t, kernel, activationCodec)
+ jobStore := mustNewTypedStore(t, kernel, jobCodec)
+ triggerStore := mustNewTypedStore(t, kernel, triggerCodec)
+ bridgeStore := mustNewTypedStore(t, kernel, bridgeCodec)
+
+ h := &bundleResourceIntegrationHarness{
+ ctx: ctx,
+ db: db,
+ kernel: kernel,
+ codecs: codecs,
+ actor: actor,
+ bundles: bundleStore,
+ activations: activationStore,
+ jobs: jobStore,
+ triggers: triggerStore,
+ bridges: bridgeStore,
+ }
+ resourceStore, err := NewResourceStore(ResourceStoreConfig{
+ Bundles: bundleStore,
+ BundleCodec: bundleCodec,
+ Activations: activationStore,
+ ActivationCodec: activationCodec,
+ Jobs: jobStore,
+ JobCodec: jobCodec,
+ Triggers: triggerStore,
+ TriggerCodec: triggerCodec,
+ Bridges: bridgeStore,
+ BridgeCodec: bridgeCodec,
+ Actor: actor,
+ Trigger: func(_ context.Context, kind resources.ResourceKind, _ resources.ReconcileReason) error {
+ h.triggeredKinds = append(h.triggeredKinds, kind)
+ return nil
+ },
+ Now: func() time.Time {
+ return time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
+ },
+ })
+ if err != nil {
+ t.Fatalf("NewResourceStore() error = %v", err)
+ }
+ h.resourceStore = resourceStore
+ h.service = NewService(
+ resourceStore,
+ staticExtensionLister{},
+ func(name string) (*extensionpkg.Extension, error) {
+ if name != "marketing-team" {
+ return nil, extensionpkg.ErrExtensionNotFound
+ }
+ return newMarketingExtension(), nil
+ },
+ WithConfiguredDefaultChannel("default"),
+ WithNow(func() time.Time {
+ return time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
+ }),
+ )
+ if h.service == nil {
+ t.Fatal("NewService() = nil, want service")
+ }
+ return h
+}
+
+func mustNewTypedStore[T any](
+ t *testing.T,
+ raw resources.RawStore,
+ codec resources.KindCodec[T],
+) resources.Store[T] {
+ t.Helper()
+
+ store, err := resources.NewStore(raw, codec)
+ if err != nil {
+ t.Fatalf("NewStore(%q) error = %v", codec.Kind(), err)
+ }
+ return store
+}
+
+func (h *bundleResourceIntegrationHarness) putMarketingBundle(t *testing.T) {
+ t.Helper()
+
+ ext := newMarketingExtension()
+ _, err := h.bundles.Put(h.ctx, h.actor, resources.Draft[BundleResourceSpec]{
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ OwnerBridgePlatform: ext.Manifest.Bridge.Platform,
+ OwnerProvidesBridgeAdapter: true,
+ },
+ })
+ if err != nil {
+ t.Fatalf("Put(bundle) error = %v", err)
+ }
+}
+
+func (h *bundleResourceIntegrationHarness) putJob(
+ t *testing.T,
+ actor resources.MutationActor,
+ job automationpkg.Job,
+) {
+ t.Helper()
+
+ _, err := h.jobs.Put(h.ctx, actor, resources.Draft[automationpkg.Job]{
+ ID: job.ID,
+ Scope: automationpkg.ResourceScopeForAutomation(job.Scope, job.WorkspaceID),
+ Spec: job,
+ })
+ if err != nil {
+ t.Fatalf("Put(job %s) error = %v", job.ID, err)
+ }
+}
+
+func (h *bundleResourceIntegrationHarness) listOwnedJobs(
+ t *testing.T,
+ owner resources.ResourceOwner,
+) []resources.Record[automationpkg.Job] {
+ t.Helper()
+
+ records, err := h.jobs.List(h.ctx, h.actor, resources.ResourceFilter{
+ Kind: automationpkg.JobResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ t.Fatalf("List(owned jobs) error = %v", err)
+ }
+ return records
+}
+
+func (h *bundleResourceIntegrationHarness) listOwnedTriggers(
+ t *testing.T,
+ owner resources.ResourceOwner,
+) []resources.Record[automationpkg.Trigger] {
+ t.Helper()
+
+ records, err := h.triggers.List(h.ctx, h.actor, resources.ResourceFilter{
+ Kind: automationpkg.TriggerResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ t.Fatalf("List(owned triggers) error = %v", err)
+ }
+ return records
+}
+
+func (h *bundleResourceIntegrationHarness) listOwnedBridges(
+ t *testing.T,
+ owner resources.ResourceOwner,
+) []resources.Record[bridgepkg.BridgeInstanceSpec] {
+ t.Helper()
+
+ records, err := h.bridges.List(h.ctx, h.actor, resources.ResourceFilter{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ t.Fatalf("List(owned bridges) error = %v", err)
+ }
+ return records
+}
+
+func bundleIntegrationActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "bundle-integration",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "bundle-integration"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func marketingBridgeProviderLookup(
+ _ context.Context,
+ extensionName string,
+) (bridgepkg.BridgeProvider, bool, error) {
+ if extensionName != "marketing-team" {
+ return bridgepkg.BridgeProvider{}, false, nil
+ }
+ return bridgepkg.BridgeProvider{
+ Platform: "telegram",
+ ExtensionName: "marketing-team",
+ DisplayName: "Telegram",
+ Enabled: true,
+ }, true, nil
+}
+
+func integrationJob(id string, name string) automationpkg.Job {
+ return automationpkg.Job{
+ ID: id,
+ Scope: automationpkg.AutomationScopeGlobal,
+ Name: name,
+ AgentName: "planner",
+ Prompt: "Run " + name,
+ Schedule: &automationpkg.ScheduleSpec{Mode: automationpkg.ScheduleModeEvery, Interval: "1h"},
+ Enabled: true,
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ Source: automationpkg.JobSourcePackage,
+ CreatedAt: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC),
+ }
+}
+
+func assertNoLegacyBundleActivationTable(t *testing.T, db *sql.DB) {
+ t.Helper()
+
+ for _, table := range []string{"bundle_activations", "bundle_activation_inventory"} {
+ var count int
+ if err := db.QueryRowContext(
+ testutil.Context(t),
+ `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`,
+ table,
+ ).Scan(&count); err != nil {
+ t.Fatalf("query sqlite_master for %s error = %v", table, err)
+ }
+ if count != 0 {
+ t.Fatalf("legacy table %s exists, want absent", table)
+ }
+ }
+}
diff --git a/internal/bundles/resource_projection.go b/internal/bundles/resource_projection.go
new file mode 100644
index 000000000..7cfa06a41
--- /dev/null
+++ b/internal/bundles/resource_projection.go
@@ -0,0 +1,285 @@
+package bundles
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+// BundleActivationResourcePlan is the owned-resource composition plan produced from bundle.activation records.
+type BundleActivationResourcePlan struct {
+ revision int64
+ operations int
+ activeActivationIDs map[string]struct{}
+ desiredJobs []automationpkg.Job
+ desiredTriggers []automationpkg.Trigger
+ desiredBridges []bridgepkg.BridgeInstance
+ jobOwners map[string]string
+ triggerOwners map[string]string
+ bridgeOwners map[string]string
+ effectiveDefault string
+ effectiveSource string
+ declaredChannels []DeclaredChannel
+}
+
+var _ resources.ProjectionPlan = (*BundleActivationResourcePlan)(nil)
+
+func (p *BundleActivationResourcePlan) Kind() resources.ResourceKind {
+ return BundleActivationResourceKind
+}
+
+func (p *BundleActivationResourcePlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *BundleActivationResourcePlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+// Build composes active bundle activations with bundle catalog dependency records.
+func (s *Service) Build(
+ ctx context.Context,
+ activationRecords []resources.Record[ActivationResourceSpec],
+ bundleRecords []resources.Record[BundleResourceSpec],
+) (resources.ProjectionPlan, error) {
+ if err := s.checkReady(ctx); err != nil {
+ return nil, err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ activations := make([]Activation, 0, len(activationRecords))
+ var revision int64
+ for _, record := range activationRecords {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ activations = append(activations, activationFromResourceRecord(record))
+ }
+ for _, record := range bundleRecords {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ }
+
+ state, err := s.collectDesiredStateFromBundleRecords(ctx, activations, bundleRecords)
+ if err != nil {
+ return nil, err
+ }
+ operations := len(state.desiredJobs) + len(state.desiredTriggers) + len(state.desiredBridges)
+ jobOwners, triggerOwners, bridgeOwners := ownedResourceMaps(state.inventoryByActivation)
+ return &BundleActivationResourcePlan{
+ revision: revision,
+ operations: operations,
+ activeActivationIDs: cloneStringSet(state.activeActivationIDs),
+ desiredJobs: cloneJobsForBundle(state.desiredJobs),
+ desiredTriggers: cloneTriggersForBundle(state.desiredTriggers),
+ desiredBridges: cloneBridgeInstancesForBundle(state.desiredBridges),
+ jobOwners: jobOwners,
+ triggerOwners: triggerOwners,
+ bridgeOwners: bridgeOwners,
+ effectiveDefault: strings.TrimSpace(state.effectiveDefault),
+ effectiveSource: strings.TrimSpace(state.effectiveSource),
+ declaredChannels: append([]DeclaredChannel(nil), state.declaredChannels...),
+ }, nil
+}
+
+func ownedResourceMaps(
+ inventoryByActivation map[string][]InventoryItem,
+) (map[string]string, map[string]string, map[string]string) {
+ jobOwners := make(map[string]string)
+ triggerOwners := make(map[string]string)
+ bridgeOwners := make(map[string]string)
+ for activationID, items := range inventoryByActivation {
+ ownerID := strings.TrimSpace(activationID)
+ for _, item := range items {
+ switch resources.ResourceKind(strings.TrimSpace(item.ResourceKind)) {
+ case automationpkg.JobResourceKind:
+ jobOwners[strings.TrimSpace(item.ResourceID)] = ownerID
+ case automationpkg.TriggerResourceKind:
+ triggerOwners[strings.TrimSpace(item.ResourceID)] = ownerID
+ case bridgepkg.BridgeInstanceResourceKind:
+ bridgeOwners[strings.TrimSpace(item.ResourceID)] = ownerID
+ }
+ }
+ }
+ return jobOwners, triggerOwners, bridgeOwners
+}
+
+// Apply writes owned automation and bridge desired-state records through canonical stores.
+func (s *Service) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if err := s.checkReady(ctx); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ typed, ok := plan.(*BundleActivationResourcePlan)
+ if !ok {
+ return fmt.Errorf("bundles: activation resource plan has type %T", plan)
+ }
+ if typed == nil {
+ return errors.New("bundles: activation resource plan is required")
+ }
+ if err := s.store.ApplyBundleActivationResources(ctx, *typed); err != nil {
+ return err
+ }
+ s.applyNetworkSettings(typed.effectiveDefault, typed.effectiveSource, typed.declaredChannels)
+ return nil
+}
+
+func (s *Service) collectDesiredStateFromBundleRecords(
+ ctx context.Context,
+ activations []Activation,
+ bundleRecords []resources.Record[BundleResourceSpec],
+) (reconcileState, error) {
+ state := reconcileState{
+ activeActivationIDs: make(map[string]struct{}, len(activations)),
+ desiredJobs: make([]automationpkg.Job, 0),
+ desiredTriggers: make([]automationpkg.Trigger, 0),
+ desiredBridges: make([]bridgepkg.BridgeInstance, 0),
+ inventoryByActivation: make(map[string][]InventoryItem, len(activations)),
+ declaredChannels: make([]DeclaredChannel, 0),
+ effectiveDefault: strings.TrimSpace(s.configuredDefault),
+ effectiveSource: "config",
+ }
+
+ claimedActivation := ""
+ errs := make([]error, 0)
+ for _, activation := range activations {
+ state.activeActivationIDs[strings.TrimSpace(activation.ID)] = struct{}{}
+ resolved, resolveErr := s.resolveActivationFromBundleRecords(activation, bundleRecords)
+ if resolveErr != nil {
+ errs = append(errs, resolveErr)
+ state.inventoryByActivation[activation.ID] = nil
+ continue
+ }
+
+ state.inventoryByActivation[activation.ID] = cloneInventoryItems(resolved.inventory)
+ state.declaredChannels = append(state.declaredChannels, resolved.channels...)
+ state.desiredJobs = append(state.desiredJobs, resolved.jobs...)
+ state.desiredTriggers = append(state.desiredTriggers, resolved.triggers...)
+ state.desiredBridges = append(state.desiredBridges, resolved.bridges...)
+ s.warnSpecHashDrift(ctx, activation, resolved.specContentHash)
+
+ claimedActivation, state.effectiveDefault, state.effectiveSource, resolveErr =
+ resolveActivationDefaultChannel(
+ activation,
+ resolved.profile,
+ claimedActivation,
+ state.effectiveDefault,
+ state.effectiveSource,
+ )
+ if resolveErr != nil {
+ errs = append(errs, resolveErr)
+ }
+ }
+ if len(errs) > 0 {
+ return reconcileState{}, errors.Join(errs...)
+ }
+ return state, nil
+}
+
+func (s *Service) resolveActivationFromBundleRecords(
+ activation Activation,
+ bundleRecords []resources.Record[BundleResourceSpec],
+) (resolvedActivation, error) {
+ if err := activation.Validate(); err != nil {
+ return resolvedActivation{}, err
+ }
+ bundleRecord, ok, err := findBundleResourceRecord(
+ bundleRecords,
+ activation.ExtensionName,
+ activation.BundleName,
+ )
+ if err != nil {
+ return resolvedActivation{}, err
+ }
+ if !ok {
+ return resolvedActivation{}, fmt.Errorf(
+ "%w: %s/%s",
+ ErrBundleNotFound,
+ activation.ExtensionName,
+ activation.BundleName,
+ )
+ }
+ bundle := cloneBundleSpec(bundleRecord.Spec.Bundle)
+ profile, ok := findProfile(bundle.Profiles, activation.ProfileName)
+ if !ok {
+ return resolvedActivation{}, fmt.Errorf(
+ "%w: %s/%s/%s",
+ ErrProfileNotFound,
+ activation.ExtensionName,
+ activation.BundleName,
+ activation.ProfileName,
+ )
+ }
+ specContentHash, err := bundleProfileSpecContentHash(bundle, profile)
+ if err != nil {
+ return resolvedActivation{}, err
+ }
+ resolved := resolvedActivation{
+ activation: activation,
+ bundleRecord: bundleRecord,
+ bundle: cloneBundleSpec(bundle),
+ profile: cloneBundleProfile(profile),
+ specContentHash: specContentHash,
+ }
+ resolved.channels = declaredChannelsForProfile(activation, bundle, profile)
+ resolved.jobs, resolved.triggers, resolved.bridges, resolved.inventory, err =
+ s.materializeActivationResources(activation, bundleRecord, bundle, profile)
+ if err != nil {
+ return resolvedActivation{}, err
+ }
+ return resolved, nil
+}
+
+func cloneStringSet(values map[string]struct{}) map[string]struct{} {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make(map[string]struct{}, len(values))
+ for value := range values {
+ cloned[strings.TrimSpace(value)] = struct{}{}
+ }
+ return cloned
+}
+
+func cloneJobsForBundle(values []automationpkg.Job) []automationpkg.Job {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make([]automationpkg.Job, 0, len(values))
+ cloned = append(cloned, values...)
+ return cloned
+}
+
+func cloneTriggersForBundle(values []automationpkg.Trigger) []automationpkg.Trigger {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make([]automationpkg.Trigger, 0, len(values))
+ cloned = append(cloned, values...)
+ return cloned
+}
+
+func cloneBridgeInstancesForBundle(values []bridgepkg.BridgeInstance) []bridgepkg.BridgeInstance {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make([]bridgepkg.BridgeInstance, 0, len(values))
+ cloned = append(cloned, values...)
+ return cloned
+}
diff --git a/internal/bundles/resource_store.go b/internal/bundles/resource_store.go
new file mode 100644
index 000000000..d128134af
--- /dev/null
+++ b/internal/bundles/resource_store.go
@@ -0,0 +1,672 @@
+package bundles
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+// ResourceStore persists bundles, activations, and owned activation fan-out through canonical resources.
+type ResourceStore struct {
+ bundles resources.Store[BundleResourceSpec]
+ bundleCodec resources.KindCodec[BundleResourceSpec]
+ activations resources.Store[ActivationResourceSpec]
+ activationCodec resources.KindCodec[ActivationResourceSpec]
+ jobs resources.Store[automationpkg.Job]
+ jobCodec resources.KindCodec[automationpkg.Job]
+ triggers resources.Store[automationpkg.Trigger]
+ triggerCodec resources.KindCodec[automationpkg.Trigger]
+ bridges resources.Store[bridgepkg.BridgeInstanceSpec]
+ bridgeCodec resources.KindCodec[bridgepkg.BridgeInstanceSpec]
+ actor resources.MutationActor
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ now func() time.Time
+}
+
+// ResourceStoreConfig groups the typed stores used by bundle resource projection.
+type ResourceStoreConfig struct {
+ Bundles resources.Store[BundleResourceSpec]
+ BundleCodec resources.KindCodec[BundleResourceSpec]
+ Activations resources.Store[ActivationResourceSpec]
+ ActivationCodec resources.KindCodec[ActivationResourceSpec]
+ Jobs resources.Store[automationpkg.Job]
+ JobCodec resources.KindCodec[automationpkg.Job]
+ Triggers resources.Store[automationpkg.Trigger]
+ TriggerCodec resources.KindCodec[automationpkg.Trigger]
+ Bridges resources.Store[bridgepkg.BridgeInstanceSpec]
+ BridgeCodec resources.KindCodec[bridgepkg.BridgeInstanceSpec]
+ Actor resources.MutationActor
+ Trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ Now func() time.Time
+}
+
+var _ Store = (*ResourceStore)(nil)
+
+var bundleActivationOwnedKindAllowlist = map[resources.ResourceKind]struct{}{
+ automationpkg.JobResourceKind: {},
+ automationpkg.TriggerResourceKind: {},
+ bridgepkg.BridgeInstanceResourceKind: {},
+}
+
+func bundleActivationOwnedKindAllowed(kind resources.ResourceKind) bool {
+ _, ok := bundleActivationOwnedKindAllowlist[kind.Normalize()]
+ return ok
+}
+
+// NewResourceStore constructs a resource-backed bundle store.
+func NewResourceStore(cfg ResourceStoreConfig) (*ResourceStore, error) {
+ if cfg.Bundles == nil {
+ return nil, errors.New("bundles: bundle resource store is required")
+ }
+ if cfg.BundleCodec == nil {
+ return nil, errors.New("bundles: bundle resource codec is required")
+ }
+ if cfg.Activations == nil {
+ return nil, errors.New("bundles: activation resource store is required")
+ }
+ if cfg.ActivationCodec == nil {
+ return nil, errors.New("bundles: activation resource codec is required")
+ }
+ if cfg.Jobs == nil || cfg.JobCodec == nil {
+ return nil, errors.New("bundles: automation job resource store and codec are required")
+ }
+ if cfg.Triggers == nil || cfg.TriggerCodec == nil {
+ return nil, errors.New("bundles: automation trigger resource store and codec are required")
+ }
+ if cfg.Bridges == nil || cfg.BridgeCodec == nil {
+ return nil, errors.New("bundles: bridge instance resource store and codec are required")
+ }
+ if cfg.Actor.Kind == "" {
+ cfg.Actor = defaultBundleResourceActor()
+ }
+ if cfg.Now == nil {
+ cfg.Now = func() time.Time { return time.Now().UTC() }
+ }
+ return &ResourceStore{
+ bundles: cfg.Bundles,
+ bundleCodec: cfg.BundleCodec,
+ activations: cfg.Activations,
+ activationCodec: cfg.ActivationCodec,
+ jobs: cfg.Jobs,
+ jobCodec: cfg.JobCodec,
+ triggers: cfg.Triggers,
+ triggerCodec: cfg.TriggerCodec,
+ bridges: cfg.Bridges,
+ bridgeCodec: cfg.BridgeCodec,
+ actor: cfg.Actor,
+ trigger: cfg.Trigger,
+ now: cfg.Now,
+ }, nil
+}
+
+func defaultBundleResourceActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "bundle-resource",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "bundle-resource"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *ResourceStore) CreateBundleActivation(ctx context.Context, activation Activation) error {
+ if err := activation.Validate(); err != nil {
+ return err
+ }
+ if activation.CreatedAt.IsZero() {
+ activation.CreatedAt = s.now().UTC()
+ }
+ if activation.UpdatedAt.IsZero() {
+ activation.UpdatedAt = activation.CreatedAt
+ }
+ _, err := s.activations.Put(ctx, s.actor, resources.Draft[ActivationResourceSpec]{
+ ID: strings.TrimSpace(activation.ID),
+ Scope: resourceScopeForActivation(activation),
+ ExpectedVersion: 0,
+ Spec: activationResourceSpecFromActivation(activation),
+ })
+ if err != nil {
+ return fmt.Errorf("bundles: create activation resource %q: %w", activation.ID, err)
+ }
+ return nil
+}
+
+func (s *ResourceStore) UpdateBundleActivation(ctx context.Context, activation Activation) error {
+ if err := activation.Validate(); err != nil {
+ return err
+ }
+ current, err := s.activations.Get(ctx, s.actor, strings.TrimSpace(activation.ID))
+ if err != nil {
+ return mapActivationResourceError("get", activation.ID, err)
+ }
+ if activation.UpdatedAt.IsZero() {
+ activation.UpdatedAt = s.now().UTC()
+ }
+ _, err = s.activations.Put(ctx, s.actor, resources.Draft[ActivationResourceSpec]{
+ ID: strings.TrimSpace(activation.ID),
+ Scope: resourceScopeForActivation(activation),
+ ExpectedVersion: current.Version,
+ Spec: activationResourceSpecFromActivation(activation),
+ })
+ if err != nil {
+ return fmt.Errorf("bundles: update activation resource %q: %w", activation.ID, err)
+ }
+ return nil
+}
+
+func (s *ResourceStore) DeleteBundleActivation(ctx context.Context, id string) error {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return errors.New("bundles: activation id is required")
+ }
+ current, err := s.activations.Get(ctx, s.actor, trimmed)
+ if err != nil {
+ return mapActivationResourceError("get", trimmed, err)
+ }
+ if err := s.activations.Delete(ctx, s.actor, trimmed, current.Version); err != nil {
+ return mapActivationResourceError("delete", trimmed, err)
+ }
+ return nil
+}
+
+func (s *ResourceStore) GetBundleActivation(ctx context.Context, id string) (Activation, error) {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return Activation{}, errors.New("bundles: activation id is required")
+ }
+ record, err := s.activations.Get(ctx, s.actor, trimmed)
+ if err != nil {
+ return Activation{}, mapActivationResourceError("get", trimmed, err)
+ }
+ return activationFromResourceRecord(record), nil
+}
+
+func (s *ResourceStore) ListBundleActivations(ctx context.Context) ([]Activation, error) {
+ records, err := s.activations.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: BundleActivationResourceKind,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("bundles: list activation resources: %w", err)
+ }
+ activations := make([]Activation, 0, len(records))
+ for _, record := range records {
+ activations = append(activations, activationFromResourceRecord(record))
+ }
+ slices.SortFunc(activations, compareActivations)
+ return activations, nil
+}
+
+func (s *ResourceStore) ListBundleResources(
+ ctx context.Context,
+) ([]resources.Record[BundleResourceSpec], error) {
+ records, err := s.bundles.List(ctx, s.actor, resources.ResourceFilter{Kind: BundleResourceKind})
+ if err != nil {
+ return nil, fmt.Errorf("bundles: list bundle resources: %w", err)
+ }
+ slices.SortFunc(records, func(left, right resources.Record[BundleResourceSpec]) int {
+ if cmp := strings.Compare(left.Spec.ExtensionName, right.Spec.ExtensionName); cmp != 0 {
+ return cmp
+ }
+ return strings.Compare(left.Spec.Bundle.Name, right.Spec.Bundle.Name)
+ })
+ return records, nil
+}
+
+func (s *ResourceStore) ListBundleActivationInventory(
+ ctx context.Context,
+ activationID string,
+) ([]InventoryItem, error) {
+ owner := ownerForActivation(activationID)
+ if err := owner.Validate("owner"); err != nil {
+ return nil, err
+ }
+ items := make([]InventoryItem, 0)
+ jobRecords, err := s.jobs.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: automationpkg.JobResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("bundles: list owned automation jobs: %w", err)
+ }
+ for _, record := range jobRecords {
+ items = append(items, InventoryItem{
+ ActivationID: owner.ID,
+ ResourceKind: string(automationpkg.JobResourceKind),
+ ResourceID: record.ID,
+ ResourceName: record.Spec.Name,
+ RecordedAtUTC: record.UpdatedAt,
+ })
+ }
+ triggerRecords, err := s.triggers.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: automationpkg.TriggerResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("bundles: list owned automation triggers: %w", err)
+ }
+ for _, record := range triggerRecords {
+ items = append(items, InventoryItem{
+ ActivationID: owner.ID,
+ ResourceKind: string(automationpkg.TriggerResourceKind),
+ ResourceID: record.ID,
+ ResourceName: record.Spec.Name,
+ RecordedAtUTC: record.UpdatedAt,
+ })
+ }
+ bridgeRecords, err := s.bridges.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ Owner: &owner,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("bundles: list owned bridge instances: %w", err)
+ }
+ for _, record := range bridgeRecords {
+ items = append(items, InventoryItem{
+ ActivationID: owner.ID,
+ ResourceKind: string(bridgepkg.BridgeInstanceResourceKind),
+ ResourceID: record.ID,
+ ResourceName: record.Spec.DisplayName,
+ RecordedAtUTC: record.UpdatedAt,
+ })
+ }
+ slices.SortFunc(items, compareInventoryItems)
+ return items, nil
+}
+
+func (s *ResourceStore) ApplyBundleActivationResources(
+ ctx context.Context,
+ plan BundleActivationResourcePlan,
+) error {
+ changed := make(map[resources.ResourceKind]struct{}, 3)
+ if err := s.syncOwnedJobResources(ctx, plan, changed); err != nil {
+ return err
+ }
+ if err := s.syncOwnedTriggerResources(ctx, plan, changed); err != nil {
+ return err
+ }
+ if err := s.syncOwnedBridgeResources(ctx, plan, changed); err != nil {
+ return err
+ }
+ return s.triggerChangedKinds(ctx, changed)
+}
+
+func (s *ResourceStore) syncOwnedJobResources(
+ ctx context.Context,
+ plan BundleActivationResourcePlan,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ desired := make(map[string]map[string]automationpkg.Job)
+ for _, job := range plan.desiredJobs {
+ ownerID := strings.TrimSpace(plan.jobOwners[strings.TrimSpace(job.ID)])
+ if ownerID == "" {
+ return fmt.Errorf("bundles: owned automation job %q has no activation owner", job.ID)
+ }
+ if desired[ownerID] == nil {
+ desired[ownerID] = make(map[string]automationpkg.Job)
+ }
+ desired[ownerID][job.ID] = job
+ }
+ return syncOwnedResources(
+ automationpkg.JobResourceKind,
+ plan.activeActivationIDs,
+ changed,
+ func(owner resources.ResourceOwner) ([]resources.Record[automationpkg.Job], error) {
+ return s.jobs.List(
+ ctx,
+ s.actor,
+ resources.ResourceFilter{Kind: automationpkg.JobResourceKind, Owner: &owner},
+ )
+ },
+ func(ownerID string, current map[string]resources.Record[automationpkg.Job]) error {
+ return s.upsertOwnedJobs(ctx, ownerID, current, desired[ownerID], changed)
+ },
+ func(ownerID string) resources.MutationActor { return activationResourceActor(s.actor, ownerID) },
+ func(actor resources.MutationActor, stale resources.Record[automationpkg.Job]) error {
+ return s.jobs.Delete(ctx, actor, stale.ID, stale.Version)
+ },
+ func() ([]resources.Record[automationpkg.Job], error) {
+ return s.jobs.List(ctx, s.actor, resources.ResourceFilter{Kind: automationpkg.JobResourceKind})
+ },
+ )
+}
+
+func (s *ResourceStore) syncOwnedTriggerResources(
+ ctx context.Context,
+ plan BundleActivationResourcePlan,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ desired := make(map[string]map[string]automationpkg.Trigger)
+ for _, trigger := range plan.desiredTriggers {
+ ownerID := strings.TrimSpace(plan.triggerOwners[strings.TrimSpace(trigger.ID)])
+ if ownerID == "" {
+ return fmt.Errorf("bundles: owned automation trigger %q has no activation owner", trigger.ID)
+ }
+ if desired[ownerID] == nil {
+ desired[ownerID] = make(map[string]automationpkg.Trigger)
+ }
+ desired[ownerID][trigger.ID] = trigger
+ }
+ return syncOwnedResources(
+ automationpkg.TriggerResourceKind,
+ plan.activeActivationIDs,
+ changed,
+ func(owner resources.ResourceOwner) ([]resources.Record[automationpkg.Trigger], error) {
+ return s.triggers.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: automationpkg.TriggerResourceKind,
+ Owner: &owner,
+ })
+ },
+ func(ownerID string, current map[string]resources.Record[automationpkg.Trigger]) error {
+ return s.upsertOwnedTriggers(ctx, ownerID, current, desired[ownerID], changed)
+ },
+ func(ownerID string) resources.MutationActor { return activationResourceActor(s.actor, ownerID) },
+ func(actor resources.MutationActor, stale resources.Record[automationpkg.Trigger]) error {
+ return s.triggers.Delete(ctx, actor, stale.ID, stale.Version)
+ },
+ func() ([]resources.Record[automationpkg.Trigger], error) {
+ return s.triggers.List(ctx, s.actor, resources.ResourceFilter{Kind: automationpkg.TriggerResourceKind})
+ },
+ )
+}
+
+func (s *ResourceStore) syncOwnedBridgeResources(
+ ctx context.Context,
+ plan BundleActivationResourcePlan,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ desired := make(map[string]map[string]bridgepkg.BridgeInstanceSpec)
+ for _, instance := range plan.desiredBridges {
+ ownerID := strings.TrimSpace(plan.bridgeOwners[strings.TrimSpace(instance.ID)])
+ if ownerID == "" {
+ return fmt.Errorf("bundles: owned bridge instance %q has no activation owner", instance.ID)
+ }
+ if desired[ownerID] == nil {
+ desired[ownerID] = make(map[string]bridgepkg.BridgeInstanceSpec)
+ }
+ desired[ownerID][instance.ID] = bridgepkg.BridgeInstanceSpecFromInstance(instance)
+ }
+ return syncOwnedResources(
+ bridgepkg.BridgeInstanceResourceKind,
+ plan.activeActivationIDs,
+ changed,
+ func(owner resources.ResourceOwner) ([]resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ return s.bridges.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ Owner: &owner,
+ })
+ },
+ func(ownerID string, current map[string]resources.Record[bridgepkg.BridgeInstanceSpec]) error {
+ return s.upsertOwnedBridges(ctx, ownerID, current, desired[ownerID], changed)
+ },
+ func(ownerID string) resources.MutationActor { return activationResourceActor(s.actor, ownerID) },
+ func(actor resources.MutationActor, stale resources.Record[bridgepkg.BridgeInstanceSpec]) error {
+ return s.bridges.Delete(ctx, actor, stale.ID, stale.Version)
+ },
+ func() ([]resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ return s.bridges.List(ctx, s.actor, resources.ResourceFilter{Kind: bridgepkg.BridgeInstanceResourceKind})
+ },
+ )
+}
+
+func (s *ResourceStore) upsertOwnedJobs(
+ ctx context.Context,
+ ownerID string,
+ current map[string]resources.Record[automationpkg.Job],
+ desired map[string]automationpkg.Job,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ actor := activationResourceActor(s.actor, ownerID)
+ for id, job := range desired {
+ existing, ok := current[id]
+ if ok && existing.Scope == automationpkg.ResourceScopeForAutomation(job.Scope, job.WorkspaceID) &&
+ s.sameJob(existing, job) {
+ delete(current, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.jobs.Put(ctx, actor, resources.Draft[automationpkg.Job]{
+ ID: id,
+ Scope: automationpkg.ResourceScopeForAutomation(job.Scope, job.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: job,
+ }); err != nil {
+ return fmt.Errorf("bundles: upsert owned automation job %q: %w", id, err)
+ }
+ changed[automationpkg.JobResourceKind] = struct{}{}
+ delete(current, id)
+ }
+ return deleteStaleOwnedRecords(ctx, actor, current, changed, automationpkg.JobResourceKind, s.jobs.Delete)
+}
+
+func (s *ResourceStore) upsertOwnedTriggers(
+ ctx context.Context,
+ ownerID string,
+ current map[string]resources.Record[automationpkg.Trigger],
+ desired map[string]automationpkg.Trigger,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ actor := activationResourceActor(s.actor, ownerID)
+ for id, trigger := range desired {
+ existing, ok := current[id]
+ if ok && existing.Scope == automationpkg.ResourceScopeForAutomation(trigger.Scope, trigger.WorkspaceID) &&
+ s.sameTrigger(existing, trigger) {
+ delete(current, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.triggers.Put(ctx, actor, resources.Draft[automationpkg.Trigger]{
+ ID: id,
+ Scope: automationpkg.ResourceScopeForAutomation(trigger.Scope, trigger.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: trigger,
+ }); err != nil {
+ return fmt.Errorf("bundles: upsert owned automation trigger %q: %w", id, err)
+ }
+ changed[automationpkg.TriggerResourceKind] = struct{}{}
+ delete(current, id)
+ }
+ return deleteStaleOwnedRecords(ctx, actor, current, changed, automationpkg.TriggerResourceKind, s.triggers.Delete)
+}
+
+func (s *ResourceStore) upsertOwnedBridges(
+ ctx context.Context,
+ ownerID string,
+ current map[string]resources.Record[bridgepkg.BridgeInstanceSpec],
+ desired map[string]bridgepkg.BridgeInstanceSpec,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ actor := activationResourceActor(s.actor, ownerID)
+ for id, spec := range desired {
+ existing, ok := current[id]
+ if ok && existing.Scope == bridgepkg.ResourceScopeForBridge(spec.Scope, spec.WorkspaceID) &&
+ s.sameBridge(existing, spec) {
+ delete(current, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.bridges.Put(ctx, actor, resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: id,
+ Scope: bridgepkg.ResourceScopeForBridge(spec.Scope, spec.WorkspaceID),
+ ExpectedVersion: expectedVersion,
+ Spec: spec,
+ }); err != nil {
+ return fmt.Errorf("bundles: upsert owned bridge instance %q: %w", id, err)
+ }
+ changed[bridgepkg.BridgeInstanceResourceKind] = struct{}{}
+ delete(current, id)
+ }
+ return deleteStaleOwnedRecords(ctx, actor, current, changed, bridgepkg.BridgeInstanceResourceKind, s.bridges.Delete)
+}
+
+func syncOwnedResources[T any](
+ kind resources.ResourceKind,
+ active map[string]struct{},
+ changed map[resources.ResourceKind]struct{},
+ listByOwner func(resources.ResourceOwner) ([]resources.Record[T], error),
+ upsert func(string, map[string]resources.Record[T]) error,
+ actorForOwner func(string) resources.MutationActor,
+ deleteOne func(resources.MutationActor, resources.Record[T]) error,
+ listAll func() ([]resources.Record[T], error),
+) error {
+ if !bundleActivationOwnedKindAllowed(kind) {
+ return fmt.Errorf("bundles: bundle activation cannot own resource kind %q", kind)
+ }
+ for ownerID := range active {
+ owner := ownerForActivation(ownerID)
+ records, err := listByOwner(owner)
+ if err != nil {
+ return err
+ }
+ current := make(map[string]resources.Record[T], len(records))
+ for _, record := range records {
+ current[record.ID] = record
+ }
+ if err := upsert(ownerID, current); err != nil {
+ return err
+ }
+ }
+
+ records, err := listAll()
+ if err != nil {
+ return err
+ }
+ for _, record := range records {
+ if record.Owner.Kind != BundleActivationOwnerKind {
+ continue
+ }
+ ownerID := strings.TrimSpace(record.Owner.ID)
+ if _, ok := active[ownerID]; ok {
+ continue
+ }
+ if err := deleteOne(actorForOwner(ownerID), record); err != nil {
+ return fmt.Errorf("bundles: delete stale owned %s %q: %w", kind, record.ID, err)
+ }
+ changed[kind] = struct{}{}
+ }
+ return nil
+}
+
+func deleteStaleOwnedRecords[T any](
+ ctx context.Context,
+ actor resources.MutationActor,
+ current map[string]resources.Record[T],
+ changed map[resources.ResourceKind]struct{},
+ kind resources.ResourceKind,
+ deleteFunc func(context.Context, resources.MutationActor, string, int64) error,
+) error {
+ for _, stale := range current {
+ if err := deleteFunc(ctx, actor, stale.ID, stale.Version); err != nil {
+ return fmt.Errorf("bundles: delete stale owned %s %q: %w", kind, stale.ID, err)
+ }
+ changed[kind] = struct{}{}
+ }
+ return nil
+}
+
+func (s *ResourceStore) sameJob(record resources.Record[automationpkg.Job], desired automationpkg.Job) bool {
+ return sameEncodedSpec(s.jobCodec, record.Scope, record.Spec, desired)
+}
+
+func (s *ResourceStore) sameTrigger(
+ record resources.Record[automationpkg.Trigger],
+ desired automationpkg.Trigger,
+) bool {
+ return sameEncodedSpec(s.triggerCodec, record.Scope, record.Spec, desired)
+}
+
+func (s *ResourceStore) sameBridge(
+ record resources.Record[bridgepkg.BridgeInstanceSpec],
+ desired bridgepkg.BridgeInstanceSpec,
+) bool {
+ return sameEncodedSpec(s.bridgeCodec, record.Scope, record.Spec, desired)
+}
+
+func sameEncodedSpec[T any](
+ codec resources.KindCodec[T],
+ scope resources.ResourceScope,
+ current T,
+ desired T,
+) bool {
+ currentJSON, err := codec.Encode(current)
+ if err != nil {
+ return false
+ }
+ desiredJSON, err := codec.Encode(desired)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentJSON, desiredJSON) && scope == scope.Normalize()
+}
+
+func (s *ResourceStore) triggerChangedKinds(
+ ctx context.Context,
+ changed map[resources.ResourceKind]struct{},
+) error {
+ if s.trigger == nil || len(changed) == 0 {
+ return nil
+ }
+ kinds := make([]resources.ResourceKind, 0, len(changed))
+ for kind := range changed {
+ kinds = append(kinds, kind)
+ }
+ slices.Sort(kinds)
+ var errs []error
+ for _, kind := range kinds {
+ if err := s.trigger(ctx, kind, resources.ReconcileReasonWrite); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func mapActivationResourceError(action string, id string, err error) error {
+ if errors.Is(err, resources.ErrNotFound) {
+ return ErrActivationNotFound
+ }
+ return fmt.Errorf("bundles: %s activation resource %q: %w", action, strings.TrimSpace(id), err)
+}
+
+func compareActivations(left Activation, right Activation) int {
+ if cmp := strings.Compare(left.ExtensionName, right.ExtensionName); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(left.BundleName, right.BundleName); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(left.ProfileName, right.ProfileName); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(string(left.Scope), string(right.Scope)); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(left.WorkspaceID, right.WorkspaceID); cmp != 0 {
+ return cmp
+ }
+ return strings.Compare(left.ID, right.ID)
+}
+
+func compareInventoryItems(left InventoryItem, right InventoryItem) int {
+ if cmp := strings.Compare(left.ResourceKind, right.ResourceKind); cmp != 0 {
+ return cmp
+ }
+ if cmp := strings.Compare(left.ResourceName, right.ResourceName); cmp != 0 {
+ return cmp
+ }
+ return strings.Compare(left.ResourceID, right.ResourceID)
+}
diff --git a/internal/bundles/resource_store_test.go b/internal/bundles/resource_store_test.go
new file mode 100644
index 000000000..708058c6e
--- /dev/null
+++ b/internal/bundles/resource_store_test.go
@@ -0,0 +1,629 @@
+package bundles
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
+ storepkg "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+type bundleResourceUnitHarness struct {
+ ctx context.Context
+ db *sql.DB
+ kernel *resources.Kernel
+ actor resources.MutationActor
+ resourceStore *ResourceStore
+ bundles resources.Store[BundleResourceSpec]
+ activations resources.Store[ActivationResourceSpec]
+ jobs resources.Store[automationpkg.Job]
+ triggers resources.Store[automationpkg.Trigger]
+ bridges resources.Store[bridgepkg.BridgeInstanceSpec]
+ triggeredKinds []resources.ResourceKind
+}
+
+func TestBundleResourceCodecsValidateAndNormalize(t *testing.T) {
+ t.Parallel()
+
+ ext := newMarketingExtension()
+ bundleCodec, err := NewBundleResourceCodec()
+ if err != nil {
+ t.Fatalf("NewBundleResourceCodec() error = %v", err)
+ }
+ decodedBundle, err := bundleCodec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ mustEncodeJSON(t, BundleResourceSpec{
+ ExtensionName: " marketing-team ",
+ Bundle: ext.Bundles[0],
+ OwnerBridgePlatform: " telegram ",
+ }),
+ )
+ if err != nil {
+ t.Fatalf("DecodeAndValidate(bundle) error = %v", err)
+ }
+ if got, want := decodedBundle.ExtensionName, "marketing-team"; got != want {
+ t.Fatalf("decodedBundle.ExtensionName = %q, want %q", got, want)
+ }
+ if !decodedBundle.OwnerProvidesBridgeAdapter {
+ t.Fatal("decodedBundle.OwnerProvidesBridgeAdapter = false, want true")
+ }
+
+ activationCodec, err := NewActivationResourceCodec()
+ if err != nil {
+ t.Fatalf("NewActivationResourceCodec() error = %v", err)
+ }
+ decodedActivation, err := activationCodec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-1"},
+ mustEncodeJSON(t, ActivationResourceSpec{
+ ExtensionName: " marketing-team ",
+ BundleName: " marketing ",
+ ProfileName: " default ",
+ SpecContentHash: " hash ",
+ }),
+ )
+ if err != nil {
+ t.Fatalf("DecodeAndValidate(activation) error = %v", err)
+ }
+ if got, want := decodedActivation.SpecContentHash, "hash"; got != want {
+ t.Fatalf("decodedActivation.SpecContentHash = %q, want %q", got, want)
+ }
+ if _, err := activationCodec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ mustEncodeJSON(t, ActivationResourceSpec{ExtensionName: "marketing-team"}),
+ ); err == nil {
+ t.Fatal("DecodeAndValidate(invalid activation) error = nil, want validation failure")
+ }
+}
+
+func TestResourceStoreActivationCRUDInventoryAndApply(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceUnitHarness(t)
+ h.putMarketingBundle(t)
+ activation := Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeGlobal, ""),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ }
+ if err := h.resourceStore.CreateBundleActivation(h.ctx, activation); err != nil {
+ t.Fatalf("CreateBundleActivation() error = %v", err)
+ }
+ loaded, err := h.resourceStore.GetBundleActivation(h.ctx, activation.ID)
+ if err != nil {
+ t.Fatalf("GetBundleActivation() error = %v", err)
+ }
+ if got, want := loaded.ID, activation.ID; got != want {
+ t.Fatalf("loaded.ID = %q, want %q", got, want)
+ }
+ loaded.BindPrimaryChannelAsDefault = true
+ if err := h.resourceStore.UpdateBundleActivation(h.ctx, loaded); err != nil {
+ t.Fatalf("UpdateBundleActivation() error = %v", err)
+ }
+ activations, err := h.resourceStore.ListBundleActivations(h.ctx)
+ if err != nil {
+ t.Fatalf("ListBundleActivations() error = %v", err)
+ }
+ if got, want := len(activations), 1; got != want {
+ t.Fatalf("len(activations) = %d, want %d", got, want)
+ }
+ bundles, err := h.resourceStore.ListBundleResources(h.ctx)
+ if err != nil {
+ t.Fatalf("ListBundleResources() error = %v", err)
+ }
+ if got, want := len(bundles), 1; got != want {
+ t.Fatalf("len(bundles) = %d, want %d", got, want)
+ }
+
+ job := unitJob("job-owned", "owned")
+ trigger := unitTrigger("trigger-owned", "owned-trigger")
+ bridge := unitBridge("bridge-owned", "Owned Bridge")
+ err = h.resourceStore.ApplyBundleActivationResources(h.ctx, BundleActivationResourcePlan{
+ activeActivationIDs: map[string]struct{}{activation.ID: {}},
+ desiredJobs: []automationpkg.Job{job},
+ desiredTriggers: []automationpkg.Trigger{trigger},
+ desiredBridges: []bridgepkg.BridgeInstance{bridge},
+ jobOwners: map[string]string{job.ID: activation.ID},
+ triggerOwners: map[string]string{trigger.ID: activation.ID},
+ bridgeOwners: map[string]string{bridge.ID: activation.ID},
+ })
+ if err != nil {
+ t.Fatalf("ApplyBundleActivationResources() error = %v", err)
+ }
+ for _, kind := range []resources.ResourceKind{
+ automationpkg.JobResourceKind,
+ automationpkg.TriggerResourceKind,
+ bridgepkg.BridgeInstanceResourceKind,
+ } {
+ if !slices.Contains(h.triggeredKinds, kind) {
+ t.Fatalf("triggered kinds = %#v, want %q", h.triggeredKinds, kind)
+ }
+ }
+ h.triggeredKinds = nil
+ if err := h.resourceStore.ApplyBundleActivationResources(h.ctx, BundleActivationResourcePlan{
+ activeActivationIDs: map[string]struct{}{activation.ID: {}},
+ desiredJobs: []automationpkg.Job{job},
+ desiredTriggers: []automationpkg.Trigger{trigger},
+ desiredBridges: []bridgepkg.BridgeInstance{bridge},
+ jobOwners: map[string]string{job.ID: activation.ID},
+ triggerOwners: map[string]string{trigger.ID: activation.ID},
+ bridgeOwners: map[string]string{bridge.ID: activation.ID},
+ }); err != nil {
+ t.Fatalf("ApplyBundleActivationResources(unchanged) error = %v", err)
+ }
+ if len(h.triggeredKinds) != 0 {
+ t.Fatalf("triggered kinds after unchanged apply = %#v, want none", h.triggeredKinds)
+ }
+ inventory, err := h.resourceStore.ListBundleActivationInventory(h.ctx, activation.ID)
+ if err != nil {
+ t.Fatalf("ListBundleActivationInventory() error = %v", err)
+ }
+ if got, want := len(inventory), 3; got != want {
+ t.Fatalf("len(inventory) = %d, want %d", got, want)
+ }
+ if err := h.resourceStore.DeleteBundleActivation(h.ctx, activation.ID); err != nil {
+ t.Fatalf("DeleteBundleActivation() error = %v", err)
+ }
+ if _, err := h.resourceStore.GetBundleActivation(h.ctx, activation.ID); !errors.Is(err, ErrActivationNotFound) {
+ t.Fatalf("GetBundleActivation(after delete) error = %v, want ErrActivationNotFound", err)
+ }
+}
+
+func TestResourceStoreWorkspaceActivationAndNotFoundErrors(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceUnitHarness(t)
+ workspaceActivation := Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeWorkspace, "ws-1"),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeWorkspace,
+ WorkspaceID: "ws-1",
+ }
+ if err := h.resourceStore.CreateBundleActivation(h.ctx, workspaceActivation); err != nil {
+ t.Fatalf("CreateBundleActivation(workspace) error = %v", err)
+ }
+ loaded, err := h.resourceStore.GetBundleActivation(h.ctx, workspaceActivation.ID)
+ if err != nil {
+ t.Fatalf("GetBundleActivation(workspace) error = %v", err)
+ }
+ if got, want := loaded.WorkspaceID, "ws-1"; got != want {
+ t.Fatalf("WorkspaceID = %q, want %q", got, want)
+ }
+ if _, err := h.resourceStore.GetBundleActivation(h.ctx, "missing"); !errors.Is(err, ErrActivationNotFound) {
+ t.Fatalf("GetBundleActivation(missing) error = %v, want ErrActivationNotFound", err)
+ }
+ if err := h.resourceStore.DeleteBundleActivation(h.ctx, ""); err == nil {
+ t.Fatal("DeleteBundleActivation(empty) error = nil, want validation failure")
+ }
+ missing := workspaceActivation
+ missing.ID = "missing"
+ if err := h.resourceStore.UpdateBundleActivation(h.ctx, missing); !errors.Is(err, ErrActivationNotFound) {
+ t.Fatalf("UpdateBundleActivation(missing) error = %v, want ErrActivationNotFound", err)
+ }
+}
+
+func TestResourceStoreCleanupDeletesOnlyOwnedInactiveActivationRecords(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceUnitHarness(t)
+ removeJob := unitJob("job-remove", "remove")
+ keepJob := unitJob("job-keep", "keep")
+ unownedJob := unitJob("job-unowned", "unowned")
+ h.putJob(t, activationResourceActor(h.actor, "act-remove"), removeJob)
+ h.putJob(t, activationResourceActor(h.actor, "act-keep"), keepJob)
+ h.putJob(t, h.actor, unownedJob)
+
+ err := h.resourceStore.ApplyBundleActivationResources(h.ctx, BundleActivationResourcePlan{
+ activeActivationIDs: map[string]struct{}{"act-keep": {}},
+ desiredJobs: []automationpkg.Job{keepJob},
+ jobOwners: map[string]string{keepJob.ID: "act-keep"},
+ })
+ if err != nil {
+ t.Fatalf("ApplyBundleActivationResources() error = %v", err)
+ }
+ if _, err := h.jobs.Get(h.ctx, h.actor, removeJob.ID); !errors.Is(err, resources.ErrNotFound) {
+ t.Fatalf("Get(removeJob) error = %v, want ErrNotFound", err)
+ }
+ if _, err := h.jobs.Get(h.ctx, h.actor, keepJob.ID); err != nil {
+ t.Fatalf("Get(keepJob) error = %v", err)
+ }
+ if _, err := h.jobs.Get(h.ctx, h.actor, unownedJob.ID); err != nil {
+ t.Fatalf("Get(unownedJob) error = %v", err)
+ }
+}
+
+func TestResourceStoreRejectsMissingOwnedResourceOwner(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceUnitHarness(t)
+ job := unitJob("job-missing-owner", "missing-owner")
+ err := h.resourceStore.ApplyBundleActivationResources(h.ctx, BundleActivationResourcePlan{
+ activeActivationIDs: map[string]struct{}{"act-missing": {}},
+ desiredJobs: []automationpkg.Job{job},
+ })
+ if err == nil {
+ t.Fatal("ApplyBundleActivationResources() error = nil, want missing owner failure")
+ }
+}
+
+func TestResourceStoreOrderingComparators(t *testing.T) {
+ t.Parallel()
+
+ base := Activation{
+ ID: "act-a",
+ ExtensionName: "ext-a",
+ BundleName: "bundle-a",
+ ProfileName: "profile-a",
+ Scope: ScopeGlobal,
+ }
+ activationCases := []struct {
+ name string
+ left Activation
+ right Activation
+ }{
+ {
+ name: "extension name",
+ left: base,
+ right: activationWith(base, func(next *Activation) { next.ExtensionName = "ext-b" }),
+ },
+ {
+ name: "bundle name",
+ left: base,
+ right: activationWith(base, func(next *Activation) { next.BundleName = "bundle-b" }),
+ },
+ {
+ name: "profile name",
+ left: base,
+ right: activationWith(base, func(next *Activation) { next.ProfileName = "profile-b" }),
+ },
+ {
+ name: "scope",
+ left: base,
+ right: activationWith(base, func(next *Activation) { next.Scope = ScopeWorkspace }),
+ },
+ {
+ name: "workspace id",
+ left: activationWith(base, func(next *Activation) { next.WorkspaceID = "ws-a" }),
+ right: activationWith(base, func(next *Activation) { next.WorkspaceID = "ws-b" }),
+ },
+ {
+ name: "id",
+ left: base,
+ right: activationWith(base, func(next *Activation) { next.ID = "act-b" }),
+ },
+ }
+ for _, tc := range activationCases {
+ t.Run("activation/"+tc.name, func(t *testing.T) {
+ if compareActivations(tc.left, tc.right) >= 0 {
+ t.Fatalf("compareActivations(%s) >= 0, want left before right", tc.name)
+ }
+ })
+ }
+
+ inventoryCases := []struct {
+ name string
+ left InventoryItem
+ right InventoryItem
+ }{
+ {
+ name: "kind",
+ left: InventoryItem{ResourceKind: "automation.job", ResourceName: "same", ResourceID: "same"},
+ right: InventoryItem{ResourceKind: "bridge.instance", ResourceName: "same", ResourceID: "same"},
+ },
+ {
+ name: "name",
+ left: InventoryItem{ResourceKind: "automation.job", ResourceName: "alpha", ResourceID: "same"},
+ right: InventoryItem{ResourceKind: "automation.job", ResourceName: "beta", ResourceID: "same"},
+ },
+ {
+ name: "id",
+ left: InventoryItem{ResourceKind: "automation.job", ResourceName: "same", ResourceID: "id-a"},
+ right: InventoryItem{ResourceKind: "automation.job", ResourceName: "same", ResourceID: "id-b"},
+ },
+ }
+ for _, tc := range inventoryCases {
+ t.Run("inventory/"+tc.name, func(t *testing.T) {
+ if compareInventoryItems(tc.left, tc.right) >= 0 {
+ t.Fatalf("compareInventoryItems(%s) >= 0, want left before right", tc.name)
+ }
+ })
+ }
+}
+
+func activationWith(base Activation, mutate func(*Activation)) Activation {
+ next := base
+ mutate(&next)
+ return next
+}
+
+func TestNewResourceStoreAppliesDefaultActor(t *testing.T) {
+ t.Parallel()
+
+ h := newBundleResourceUnitHarness(t)
+ validConfig := ResourceStoreConfig{
+ Bundles: h.bundles,
+ BundleCodec: h.resourceStore.bundleCodec,
+ Activations: h.activations,
+ ActivationCodec: h.resourceStore.activationCodec,
+ Jobs: h.jobs,
+ JobCodec: h.resourceStore.jobCodec,
+ Triggers: h.triggers,
+ TriggerCodec: h.resourceStore.triggerCodec,
+ Bridges: h.bridges,
+ BridgeCodec: h.resourceStore.bridgeCodec,
+ }
+ store, err := NewResourceStore(validConfig)
+ if err != nil {
+ t.Fatalf("NewResourceStore(default actor) error = %v", err)
+ }
+ if got, want := store.actor.ID, "bundle-resource"; got != want {
+ t.Fatalf("store.actor.ID = %q, want %q", got, want)
+ }
+ if _, err := NewResourceStore(ResourceStoreConfig{}); err == nil {
+ t.Fatal("NewResourceStore(empty config) error = nil, want validation failure")
+ }
+
+ testCases := []struct {
+ name string
+ mutate func(*ResourceStoreConfig)
+ }{
+ {name: "bundle codec", mutate: func(cfg *ResourceStoreConfig) { cfg.BundleCodec = nil }},
+ {name: "activations", mutate: func(cfg *ResourceStoreConfig) { cfg.Activations = nil }},
+ {name: "activation codec", mutate: func(cfg *ResourceStoreConfig) { cfg.ActivationCodec = nil }},
+ {name: "job store", mutate: func(cfg *ResourceStoreConfig) { cfg.Jobs = nil }},
+ {name: "trigger store", mutate: func(cfg *ResourceStoreConfig) { cfg.Triggers = nil }},
+ {name: "bridge store", mutate: func(cfg *ResourceStoreConfig) { cfg.Bridges = nil }},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := validConfig
+ tc.mutate(&cfg)
+ if _, err := NewResourceStore(cfg); err == nil {
+ t.Fatalf("NewResourceStore(%s) error = nil, want validation failure", tc.name)
+ }
+ })
+ }
+}
+
+func newBundleResourceUnitHarness(t *testing.T) *bundleResourceUnitHarness {
+ t.Helper()
+
+ ctx := testutil.Context(t)
+ db, err := storepkg.OpenSQLiteDatabase(
+ ctx,
+ filepath.Join(t.TempDir(), storepkg.GlobalDatabaseName),
+ func(ctx context.Context, db *sql.DB) error {
+ return storepkg.EnsureSchema(ctx, db, resources.SchemaStatements())
+ },
+ )
+ if err != nil {
+ t.Fatalf("OpenSQLiteDatabase() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := db.Close(); err != nil {
+ t.Fatalf("db.Close() error = %v", err)
+ }
+ })
+ kernel, err := resources.NewKernel(db, resources.WithNow(unitNow))
+ if err != nil {
+ t.Fatalf("NewKernel() error = %v", err)
+ }
+
+ bundleCodec, err := NewBundleResourceCodec()
+ if err != nil {
+ t.Fatalf("NewBundleResourceCodec() error = %v", err)
+ }
+ activationCodec, err := NewActivationResourceCodec()
+ if err != nil {
+ t.Fatalf("NewActivationResourceCodec() error = %v", err)
+ }
+ jobCodec, err := automationpkg.NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("NewJobResourceCodec() error = %v", err)
+ }
+ triggerCodec, err := automationpkg.NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewTriggerResourceCodec() error = %v", err)
+ }
+ bridgeCodec, err := bridgepkg.NewBridgeInstanceResourceCodec(unitBridgeProviderLookup)
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+
+ bundleStore := mustNewUnitTypedStore(t, kernel, bundleCodec)
+ activationStore := mustNewUnitTypedStore(t, kernel, activationCodec)
+ jobStore := mustNewUnitTypedStore(t, kernel, jobCodec)
+ triggerStore := mustNewUnitTypedStore(t, kernel, triggerCodec)
+ bridgeStore := mustNewUnitTypedStore(t, kernel, bridgeCodec)
+ actor := unitResourceActor()
+ h := &bundleResourceUnitHarness{
+ ctx: ctx,
+ db: db,
+ kernel: kernel,
+ actor: actor,
+ bundles: bundleStore,
+ activations: activationStore,
+ jobs: jobStore,
+ triggers: triggerStore,
+ bridges: bridgeStore,
+ }
+ resourceStore, err := NewResourceStore(ResourceStoreConfig{
+ Bundles: bundleStore,
+ BundleCodec: bundleCodec,
+ Activations: activationStore,
+ ActivationCodec: activationCodec,
+ Jobs: jobStore,
+ JobCodec: jobCodec,
+ Triggers: triggerStore,
+ TriggerCodec: triggerCodec,
+ Bridges: bridgeStore,
+ BridgeCodec: bridgeCodec,
+ Actor: actor,
+ Trigger: func(_ context.Context, kind resources.ResourceKind, _ resources.ReconcileReason) error {
+ h.triggeredKinds = append(h.triggeredKinds, kind)
+ return nil
+ },
+ Now: unitNow,
+ })
+ if err != nil {
+ t.Fatalf("NewResourceStore() error = %v", err)
+ }
+ h.resourceStore = resourceStore
+ return h
+}
+
+func mustNewUnitTypedStore[T any](
+ t *testing.T,
+ raw resources.RawStore,
+ codec resources.KindCodec[T],
+) resources.Store[T] {
+ t.Helper()
+
+ store, err := resources.NewStore(raw, codec)
+ if err != nil {
+ t.Fatalf("NewStore(%q) error = %v", codec.Kind(), err)
+ }
+ return store
+}
+
+func (h *bundleResourceUnitHarness) putMarketingBundle(t *testing.T) {
+ t.Helper()
+
+ ext := newMarketingExtension()
+ _, err := h.bundles.Put(h.ctx, h.actor, resources.Draft[BundleResourceSpec]{
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ OwnerBridgePlatform: ext.Manifest.Bridge.Platform,
+ OwnerProvidesBridgeAdapter: true,
+ },
+ })
+ if err != nil {
+ t.Fatalf("Put(bundle) error = %v", err)
+ }
+}
+
+func (h *bundleResourceUnitHarness) putJob(
+ t *testing.T,
+ actor resources.MutationActor,
+ job automationpkg.Job,
+) {
+ t.Helper()
+
+ _, err := h.jobs.Put(h.ctx, actor, resources.Draft[automationpkg.Job]{
+ ID: job.ID,
+ Scope: automationpkg.ResourceScopeForAutomation(job.Scope, job.WorkspaceID),
+ Spec: job,
+ })
+ if err != nil {
+ t.Fatalf("Put(job %s) error = %v", job.ID, err)
+ }
+}
+
+func unitResourceActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "bundle-unit",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "bundle-unit"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func unitBridgeProviderLookup(
+ _ context.Context,
+ extensionName string,
+) (bridgepkg.BridgeProvider, bool, error) {
+ if extensionName != "marketing-team" {
+ return bridgepkg.BridgeProvider{}, false, nil
+ }
+ return bridgepkg.BridgeProvider{
+ Platform: "telegram",
+ ExtensionName: "marketing-team",
+ DisplayName: "Telegram",
+ Enabled: true,
+ }, true, nil
+}
+
+func unitJob(id string, name string) automationpkg.Job {
+ return automationpkg.Job{
+ ID: id,
+ Scope: automationpkg.AutomationScopeGlobal,
+ Name: name,
+ AgentName: "planner",
+ Prompt: "Run " + name,
+ Schedule: &automationpkg.ScheduleSpec{
+ Mode: automationpkg.ScheduleModeEvery,
+ Interval: "1h",
+ },
+ Enabled: true,
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ Source: automationpkg.JobSourcePackage,
+ CreatedAt: unitNow(),
+ UpdatedAt: unitNow(),
+ }
+}
+
+func unitTrigger(id string, name string) automationpkg.Trigger {
+ return automationpkg.Trigger{
+ ID: id,
+ Scope: automationpkg.AutomationScopeGlobal,
+ Name: name,
+ AgentName: "planner",
+ Prompt: "Handle " + name,
+ Event: "session.created",
+ Enabled: true,
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ Source: automationpkg.JobSourcePackage,
+ CreatedAt: unitNow(),
+ UpdatedAt: unitNow(),
+ }
+}
+
+func unitBridge(id string, name string) bridgepkg.BridgeInstance {
+ return bridgepkg.BridgeInstance{
+ ID: id,
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "marketing-team",
+ DisplayName: name,
+ Source: bridgepkg.BridgeInstanceSourcePackage,
+ Enabled: false,
+ Status: bridgepkg.BridgeStatusDisabled,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ CreatedAt: unitNow(),
+ UpdatedAt: unitNow(),
+ }
+}
+
+func unitNow() time.Time {
+ return time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC)
+}
+
+func mustEncodeJSON(t *testing.T, value any) []byte {
+ t.Helper()
+
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ return raw
+}
diff --git a/internal/bundles/resource_test.go b/internal/bundles/resource_test.go
new file mode 100644
index 000000000..1c34e266b
--- /dev/null
+++ b/internal/bundles/resource_test.go
@@ -0,0 +1,163 @@
+package bundles
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestBundleActivationOwnedKindAllowlist(t *testing.T) {
+ t.Parallel()
+
+ for _, kind := range []resources.ResourceKind{
+ automationpkg.JobResourceKind,
+ automationpkg.TriggerResourceKind,
+ bridgepkg.BridgeInstanceResourceKind,
+ } {
+ if !bundleActivationOwnedKindAllowed(kind) {
+ t.Fatalf("bundleActivationOwnedKindAllowed(%q) = false, want true", kind)
+ }
+ }
+ if bundleActivationOwnedKindAllowed(resources.ResourceKind("tool")) {
+ t.Fatal("bundleActivationOwnedKindAllowed(tool) = true, want false")
+ }
+}
+
+func TestBundleActivationBuildComposesTypedBundleDependency(t *testing.T) {
+ t.Parallel()
+
+ store := newMemoryStore()
+ service := newMarketingService(store)
+ activation := Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeGlobal, ""),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ }
+
+ bundleRecords := append([]resources.Record[BundleResourceSpec](nil), store.bundles...)
+ bundleRecords[0].Version = 9
+ plan, err := service.Build(
+ context.Background(),
+ []resources.Record[ActivationResourceSpec]{{
+ Kind: BundleActivationResourceKind,
+ ID: activation.ID,
+ Version: 3,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: activationResourceSpecFromActivation(activation),
+ }},
+ bundleRecords,
+ )
+ if err != nil {
+ t.Fatalf("Build() error = %v", err)
+ }
+ if got, want := plan.Kind(), BundleActivationResourceKind; got != want {
+ t.Fatalf("plan.Kind() = %q, want %q", got, want)
+ }
+ if got, want := plan.Revision(), int64(9); got != want {
+ t.Fatalf("plan.Revision() = %d, want %d", got, want)
+ }
+ if got, want := plan.OperationCount(), 3; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+ if err := service.Apply(context.Background(), plan); err != nil {
+ t.Fatalf("Apply() error = %v", err)
+ }
+ if got, want := len(store.applied), 1; got != want {
+ t.Fatalf("len(applied plans) = %d, want %d", got, want)
+ }
+ if err := service.Apply(context.Background(), nonBundleActivationPlan{}); err == nil {
+ t.Fatal("Apply(wrong plan type) error = nil, want failure")
+ } else if !strings.Contains(err.Error(), "activation resource plan has type") {
+ t.Fatalf("Apply(wrong plan type) error = %v, want wrong-plan-type context", err)
+ }
+}
+
+func TestBundleServiceReconcileLoadsBundleResourcesOncePerRun(t *testing.T) {
+ t.Parallel()
+
+ ext := newMarketingExtension()
+ store := &countingBundleStore{memoryStore: newMemoryStore()}
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Version: 11,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ OwnerBridgePlatform: ext.Manifest.Bridge.Platform,
+ OwnerProvidesBridgeAdapter: true,
+ },
+ }}
+ store.activations[ActivationResourceID("marketing-team", "marketing", "default", ScopeGlobal, "")] = Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeGlobal, ""),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ }
+ store.activations[ActivationResourceID("marketing-team", "marketing", "default", ScopeWorkspace, "ws-1")] = Activation{
+ ID: ActivationResourceID("marketing-team", "marketing", "default", ScopeWorkspace, "ws-1"),
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeWorkspace,
+ WorkspaceID: "ws-1",
+ }
+
+ service := NewService(
+ store,
+ staticExtensionLister{items: []extensionpkg.ExtensionInfo{{Name: "marketing-team"}}},
+ func(name string) (*extensionpkg.Extension, error) {
+ if name != "marketing-team" {
+ return nil, extensionpkg.ErrExtensionNotFound
+ }
+ return ext, nil
+ },
+ WithConfiguredDefaultChannel("default"),
+ WithNow(func() time.Time {
+ return time.Date(2026, 4, 14, 22, 0, 0, 0, time.UTC)
+ }),
+ )
+ if err := service.Reconcile(testutil.Context(t)); err != nil {
+ t.Fatalf("Reconcile() error = %v", err)
+ }
+ if got, want := store.listBundleResourcesCalls, 1; got != want {
+ t.Fatalf("ListBundleResources() calls = %d, want %d", got, want)
+ }
+}
+
+type nonBundleActivationPlan struct{}
+
+func (nonBundleActivationPlan) Kind() resources.ResourceKind {
+ return BundleActivationResourceKind
+}
+
+func (nonBundleActivationPlan) Revision() int64 {
+ return 0
+}
+
+func (nonBundleActivationPlan) OperationCount() int {
+ return 0
+}
+
+type countingBundleStore struct {
+ *memoryStore
+ listBundleResourcesCalls int
+}
+
+func (s *countingBundleStore) ListBundleResources(
+ ctx context.Context,
+) ([]resources.Record[BundleResourceSpec], error) {
+ s.listBundleResourcesCalls++
+ return s.memoryStore.ListBundleResources(ctx)
+}
diff --git a/internal/bundles/service.go b/internal/bundles/service.go
index 6a172c368..ef12759ad 100644
--- a/internal/bundles/service.go
+++ b/internal/bundles/service.go
@@ -21,6 +21,7 @@ import (
modelpkg "github.com/pedronauck/agh/internal/bundles/model"
aghconfig "github.com/pedronauck/agh/internal/config"
extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -78,26 +79,9 @@ type Store interface {
DeleteBundleActivation(ctx context.Context, id string) error
GetBundleActivation(ctx context.Context, id string) (Activation, error)
ListBundleActivations(ctx context.Context) ([]Activation, error)
- ReplaceBundleActivationInventory(ctx context.Context, activationID string, items []InventoryItem) error
ListBundleActivationInventory(ctx context.Context, activationID string) ([]InventoryItem, error)
-}
-
-type AutomationSyncer interface {
- SyncManagedDefinitions(
- ctx context.Context,
- source automationpkg.JobSource,
- desiredJobs []automationpkg.Job,
- desiredTriggers []automationpkg.Trigger,
- desiredTriggerSecrets map[string]string,
- ) (automationpkg.SyncStats, error)
-}
-
-type BridgeManagedSyncer interface {
- SyncManagedInstances(
- ctx context.Context,
- source bridgepkg.BridgeInstanceSource,
- desired []bridgepkg.BridgeInstance,
- ) (bridgepkg.ManagedSyncStats, error)
+ ListBundleResources(ctx context.Context) ([]resources.Record[BundleResourceSpec], error)
+ ApplyBundleActivationResources(ctx context.Context, plan BundleActivationResourcePlan) error
}
type ExtensionInfoLister interface {
@@ -108,8 +92,6 @@ type ExtensionLoader func(name string) (*extensionpkg.Extension, error)
type Service struct {
store Store
- automation AutomationSyncer
- bridges BridgeManagedSyncer
extensions ExtensionInfoLister
loadExtension ExtensionLoader
workspaceResolver workspacepkg.RuntimeResolver
@@ -124,18 +106,6 @@ type Service struct {
type Option func(*Service)
-func WithAutomation(syncer AutomationSyncer) Option {
- return func(s *Service) {
- s.automation = syncer
- }
-}
-
-func WithBridges(syncer BridgeManagedSyncer) Option {
- return func(s *Service) {
- s.bridges = syncer
- }
-}
-
func WithWorkspaceResolver(resolver workspacepkg.RuntimeResolver) Option {
return func(s *Service) {
s.workspaceResolver = resolver
@@ -204,23 +174,17 @@ func (s *Service) Catalog(ctx context.Context) ([]CatalogEntry, error) {
return nil, err
}
- infos, err := s.extensions.List()
+ records, err := s.store.ListBundleResources(ctx)
if err != nil {
return nil, err
}
- entries := make([]CatalogEntry, 0)
- for _, info := range infos {
- ext, loadErr := s.loadExtension(strings.TrimSpace(info.Name))
- if loadErr != nil || ext == nil {
- continue
- }
- for _, bundle := range ext.Bundles {
- entries = append(entries, CatalogEntry{
- ExtensionName: strings.TrimSpace(info.Name),
- Bundle: cloneBundleSpec(bundle),
- })
- }
+ entries := make([]CatalogEntry, 0, len(records))
+ for _, record := range records {
+ entries = append(entries, CatalogEntry{
+ ExtensionName: strings.TrimSpace(record.Spec.ExtensionName),
+ Bundle: cloneBundleSpec(record.Spec.Bundle),
+ })
}
slices.SortFunc(entries, func(left, right CatalogEntry) int {
if cmp := strings.Compare(left.ExtensionName, right.ExtensionName); cmp != 0 {
@@ -350,10 +314,17 @@ func (s *Service) GetActivation(ctx context.Context, id string) (ActivationPrevi
if err != nil {
return ActivationPreview{}, err
}
- resolved, err := s.resolveActivation(activation)
+ resolved, err := s.resolveActivation(ctx, activation)
if err != nil {
return ActivationPreview{}, err
}
+ inventory, err := s.store.ListBundleActivationInventory(ctx, activation.ID)
+ if err != nil {
+ return ActivationPreview{}, err
+ }
+ if len(inventory) > 0 {
+ resolved.inventory = inventory
+ }
return ActivationPreview{
Activation: cloneActivation(resolved.activation),
Bundle: cloneBundleSpec(resolved.bundle),
@@ -473,19 +444,25 @@ func (s *Service) reconcileLocked(ctx context.Context) error {
}
errs := make([]error, 0)
- if syncErr := s.syncDesiredResources(ctx, state); syncErr != nil {
+ jobOwners, triggerOwners, bridgeOwners := ownedResourceMaps(state.inventoryByActivation)
+ if syncErr := s.store.ApplyBundleActivationResources(ctx, BundleActivationResourcePlan{
+ activeActivationIDs: cloneStringSet(state.activeActivationIDs),
+ desiredJobs: cloneJobsForBundle(state.desiredJobs),
+ desiredTriggers: cloneTriggersForBundle(state.desiredTriggers),
+ desiredBridges: cloneBridgeInstancesForBundle(state.desiredBridges),
+ jobOwners: jobOwners,
+ triggerOwners: triggerOwners,
+ bridgeOwners: bridgeOwners,
+ }); syncErr != nil {
errs = append(errs, syncErr)
}
- if inventoryErr := s.recordActivationInventory(ctx, activations, state.inventoryByActivation); inventoryErr != nil {
- errs = append(errs, inventoryErr)
- }
s.applyNetworkSettings(state.effectiveDefault, state.effectiveSource, state.declaredChannels)
return errors.Join(errs...)
}
type resolvedActivation struct {
activation Activation
- extension *extensionpkg.Extension
+ bundleRecord resources.Record[BundleResourceSpec]
bundle extensionpkg.BundleSpec
profile extensionpkg.BundleProfile
specContentHash string
@@ -496,7 +473,15 @@ type resolvedActivation struct {
inventory []InventoryItem
}
+type activationDefinition struct {
+ bundleRecord resources.Record[BundleResourceSpec]
+ bundle extensionpkg.BundleSpec
+ profile extensionpkg.BundleProfile
+ specContentHash string
+}
+
type reconcileState struct {
+ activeActivationIDs map[string]struct{}
desiredJobs []automationpkg.Job
desiredTriggers []automationpkg.Trigger
desiredBridges []bridgepkg.BridgeInstance
@@ -533,7 +518,7 @@ func (s *Service) resolveRequest(ctx context.Context, req ActivateRequest) (reso
WorkspaceID: workspaceID,
BindPrimaryChannelAsDefault: req.BindPrimaryChannelAsDefault,
}
- resolved, err := s.resolveActivation(activation)
+ resolved, err := s.resolveActivation(ctx, activation)
if err != nil {
return resolvedActivation{}, err
}
@@ -541,29 +526,29 @@ func (s *Service) resolveRequest(ctx context.Context, req ActivateRequest) (reso
return resolved, nil
}
-func (s *Service) resolveActivation(activation Activation) (resolvedActivation, error) {
+func (s *Service) resolveActivation(ctx context.Context, activation Activation) (resolvedActivation, error) {
if err := activation.Validate(); err != nil {
return resolvedActivation{}, err
}
- ext, bundle, profile, specContentHash, err := s.resolveActivationDefinition(activation)
+ definition, err := s.resolveActivationDefinition(ctx, activation)
if err != nil {
return resolvedActivation{}, err
}
resolved := resolvedActivation{
activation: activation,
- extension: ext,
- bundle: cloneBundleSpec(bundle),
- profile: cloneBundleProfile(profile),
- specContentHash: specContentHash,
+ bundleRecord: definition.bundleRecord,
+ bundle: cloneBundleSpec(definition.bundle),
+ profile: cloneBundleProfile(definition.profile),
+ specContentHash: definition.specContentHash,
}
- resolved.channels = declaredChannelsForProfile(activation, bundle, profile)
+ resolved.channels = declaredChannelsForProfile(activation, definition.bundle, definition.profile)
resolved.jobs, resolved.triggers, resolved.bridges, resolved.inventory, err = s.materializeActivationResources(
activation,
- ext,
- bundle,
- profile,
+ definition.bundleRecord,
+ definition.bundle,
+ definition.profile,
)
if err != nil {
return resolvedActivation{}, err
@@ -572,7 +557,13 @@ func (s *Service) resolveActivation(activation Activation) (resolvedActivation,
}
func (s *Service) collectDesiredState(ctx context.Context, activations []Activation) (reconcileState, error) {
+ bundleRecords, err := s.store.ListBundleResources(ctx)
+ if err != nil {
+ return reconcileState{}, err
+ }
+
state := reconcileState{
+ activeActivationIDs: make(map[string]struct{}, len(activations)),
desiredJobs: make([]automationpkg.Job, 0),
desiredTriggers: make([]automationpkg.Trigger, 0),
desiredBridges: make([]bridgepkg.BridgeInstance, 0),
@@ -585,7 +576,8 @@ func (s *Service) collectDesiredState(ctx context.Context, activations []Activat
claimedActivation := ""
errs := make([]error, 0)
for _, activation := range activations {
- resolved, resolveErr := s.resolveActivation(activation)
+ state.activeActivationIDs[strings.TrimSpace(activation.ID)] = struct{}{}
+ resolved, resolveErr := s.resolveActivationFromBundleRecords(activation, bundleRecords)
if resolveErr != nil {
errs = append(errs, resolveErr)
state.inventoryByActivation[activation.ID] = nil
@@ -647,63 +639,6 @@ func resolveActivationDefaultChannel(
}
}
-func (s *Service) syncDesiredResources(ctx context.Context, state reconcileState) error {
- errs := make([]error, 0)
- if s.automation == nil {
- if len(state.desiredJobs) > 0 || len(state.desiredTriggers) > 0 {
- errs = append(errs, errors.New("bundles: automation syncer is required for bundle automations"))
- }
- } else if _, err := s.automation.SyncManagedDefinitions(
- ctx,
- automationpkg.JobSourcePackage,
- state.desiredJobs,
- state.desiredTriggers,
- nil,
- ); err != nil {
- errs = append(errs, err)
- }
-
- if len(state.desiredBridges) > 0 {
- if s.bridges == nil {
- errs = append(errs, errors.New("bundles: bridge syncer is required for bundle bridges"))
- } else if _, err := s.bridges.SyncManagedInstances(
- ctx,
- bridgepkg.BridgeInstanceSourcePackage,
- state.desiredBridges,
- ); err != nil {
- errs = append(errs, err)
- }
- } else if s.bridges != nil {
- if _, err := s.bridges.SyncManagedInstances(
- ctx,
- bridgepkg.BridgeInstanceSourcePackage,
- nil,
- ); err != nil {
- errs = append(errs, err)
- }
- }
- return errors.Join(errs...)
-}
-
-func (s *Service) recordActivationInventory(
- ctx context.Context,
- activations []Activation,
- inventoryByActivation map[string][]InventoryItem,
-) error {
- recordedAt := s.now().UTC()
- errs := make([]error, 0)
- for _, activation := range activations {
- items := cloneInventoryItems(inventoryByActivation[activation.ID])
- for idx := range items {
- items[idx].RecordedAtUTC = recordedAt
- }
- if err := s.store.ReplaceBundleActivationInventory(ctx, activation.ID, items); err != nil {
- errs = append(errs, err)
- }
- }
- return errors.Join(errs...)
-}
-
func (s *Service) applyNetworkSettings(
effectiveDefault string,
effectiveSource string,
@@ -733,28 +668,25 @@ func (s *Service) applyNetworkSettings(
}
func (s *Service) resolveActivationDefinition(
+ ctx context.Context,
activation Activation,
-) (*extensionpkg.Extension, extensionpkg.BundleSpec, extensionpkg.BundleProfile, string, error) {
- ext, err := s.loadExtension(activation.ExtensionName)
+) (activationDefinition, error) {
+ bundleRecord, ok, err := s.findBundleResource(ctx, activation.ExtensionName, activation.BundleName)
if err != nil {
- return nil, extensionpkg.BundleSpec{}, extensionpkg.BundleProfile{}, "", err
- }
- if ext == nil {
- return nil, extensionpkg.BundleSpec{}, extensionpkg.BundleProfile{}, "", extensionpkg.ErrExtensionNotFound
+ return activationDefinition{}, err
}
-
- bundle, ok := findBundle(ext.Bundles, activation.BundleName)
if !ok {
- return nil, extensionpkg.BundleSpec{}, extensionpkg.BundleProfile{}, "", fmt.Errorf(
+ return activationDefinition{}, fmt.Errorf(
"%w: %s/%s",
ErrBundleNotFound,
activation.ExtensionName,
activation.BundleName,
)
}
+ bundle := cloneBundleSpec(bundleRecord.Spec.Bundle)
profile, ok := findProfile(bundle.Profiles, activation.ProfileName)
if !ok {
- return nil, extensionpkg.BundleSpec{}, extensionpkg.BundleProfile{}, "", fmt.Errorf(
+ return activationDefinition{}, fmt.Errorf(
"%w: %s/%s/%s",
ErrProfileNotFound,
activation.ExtensionName,
@@ -764,9 +696,42 @@ func (s *Service) resolveActivationDefinition(
}
specContentHash, err := bundleProfileSpecContentHash(bundle, profile)
if err != nil {
- return nil, extensionpkg.BundleSpec{}, extensionpkg.BundleProfile{}, "", err
+ return activationDefinition{}, err
+ }
+ return activationDefinition{
+ bundleRecord: bundleRecord,
+ bundle: bundle,
+ profile: profile,
+ specContentHash: specContentHash,
+ }, nil
+}
+
+func (s *Service) findBundleResource(
+ ctx context.Context,
+ extensionName string,
+ bundleName string,
+) (resources.Record[BundleResourceSpec], bool, error) {
+ records, err := s.store.ListBundleResources(ctx)
+ if err != nil {
+ return resources.Record[BundleResourceSpec]{}, false, err
+ }
+ return findBundleResourceRecord(records, extensionName, bundleName)
+}
+
+func findBundleResourceRecord(
+ records []resources.Record[BundleResourceSpec],
+ extensionName string,
+ bundleName string,
+) (resources.Record[BundleResourceSpec], bool, error) {
+ trimmedExtension := strings.TrimSpace(extensionName)
+ trimmedBundle := strings.TrimSpace(bundleName)
+ for _, record := range records {
+ if strings.EqualFold(strings.TrimSpace(record.Spec.ExtensionName), trimmedExtension) &&
+ strings.EqualFold(strings.TrimSpace(record.Spec.Bundle.Name), trimmedBundle) {
+ return record, true, nil
+ }
}
- return ext, bundle, profile, specContentHash, nil
+ return resources.Record[BundleResourceSpec]{}, false, nil
}
func declaredChannelsForProfile(
@@ -792,7 +757,7 @@ func declaredChannelsForProfile(
func (s *Service) materializeActivationResources(
activation Activation,
- ext *extensionpkg.Extension,
+ bundleRecord resources.Record[BundleResourceSpec],
bundle extensionpkg.BundleSpec,
profile extensionpkg.BundleProfile,
) ([]automationpkg.Job, []automationpkg.Trigger, []bridgepkg.BridgeInstance, []InventoryItem, error) {
@@ -809,7 +774,7 @@ func (s *Service) materializeActivationResources(
jobs = append(jobs, job)
inventory = append(inventory, InventoryItem{
ActivationID: activation.ID,
- ResourceKind: "automation_job",
+ ResourceKind: string(automationpkg.JobResourceKind),
ResourceID: job.ID,
ResourceName: job.Name,
})
@@ -822,20 +787,20 @@ func (s *Service) materializeActivationResources(
triggers = append(triggers, trigger)
inventory = append(inventory, InventoryItem{
ActivationID: activation.ID,
- ResourceKind: "automation_trigger",
+ ResourceKind: string(automationpkg.TriggerResourceKind),
ResourceID: trigger.ID,
ResourceName: trigger.Name,
})
}
for _, bridgeDef := range profile.Bridges {
- instance, err := s.materializeBridge(activation, ext, bundle, profile, bridgeDef)
+ instance, err := s.materializeBridge(activation, bundleRecord, bundle, profile, bridgeDef)
if err != nil {
return nil, nil, nil, nil, err
}
bridges = append(bridges, instance)
inventory = append(inventory, InventoryItem{
ActivationID: activation.ID,
- ResourceKind: "bridge_instance",
+ ResourceKind: string(bridgepkg.BridgeInstanceResourceKind),
ResourceID: instance.ID,
ResourceName: instance.DisplayName,
})
@@ -845,7 +810,7 @@ func (s *Service) materializeActivationResources(
func (s *Service) materializeBridge(
activation Activation,
- owner *extensionpkg.Extension,
+ bundleRecord resources.Record[BundleResourceSpec],
bundle extensionpkg.BundleSpec,
profile extensionpkg.BundleProfile,
preset extensionpkg.BundleBridgePreset,
@@ -858,8 +823,17 @@ func (s *Service) materializeBridge(
platform := strings.TrimSpace(preset.Platform)
if platform == "" {
switch {
- case strings.EqualFold(extensionName, activation.ExtensionName) && owner != nil && owner.Manifest != nil:
- platform = strings.TrimSpace(owner.Manifest.Bridge.Platform)
+ case strings.EqualFold(extensionName, activation.ExtensionName):
+ platform = strings.TrimSpace(bundleRecord.Spec.OwnerBridgePlatform)
+ if platform == "" {
+ provider, err := s.loadExtension(extensionName)
+ if err != nil {
+ return bridgepkg.BridgeInstance{}, err
+ }
+ if provider != nil && provider.Manifest != nil {
+ platform = strings.TrimSpace(provider.Manifest.Bridge.Platform)
+ }
+ }
default:
provider, err := s.loadExtension(extensionName)
if err != nil {
@@ -1081,15 +1055,6 @@ func stableID(prefix string, parts ...string) string {
return prefix + "_" + hex.EncodeToString(sum[:8])
}
-func findBundle(items []extensionpkg.BundleSpec, name string) (extensionpkg.BundleSpec, bool) {
- for _, item := range items {
- if strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(name)) {
- return item, true
- }
- }
- return extensionpkg.BundleSpec{}, false
-}
-
func findProfile(items []extensionpkg.BundleProfile, name string) (extensionpkg.BundleProfile, bool) {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(name)) {
diff --git a/internal/bundles/service_test.go b/internal/bundles/service_test.go
index 14f19808c..be2819e44 100644
--- a/internal/bundles/service_test.go
+++ b/internal/bundles/service_test.go
@@ -3,6 +3,9 @@ package bundles
import (
"context"
"errors"
+ "io"
+ "log/slog"
+ "maps"
"path/filepath"
"strings"
"testing"
@@ -11,14 +14,21 @@ import (
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/testutil"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
+func discardBundleTestLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
type memoryStore struct {
activations map[string]Activation
inventory map[string][]InventoryItem
- bridges map[string]bridgepkg.BridgeInstance
+ bundles []resources.Record[BundleResourceSpec]
+ applied []BundleActivationResourcePlan
+ applyErr error
createBundleActivationHook func(Activation) error
updateBundleActivationHook func(Activation) error
@@ -29,10 +39,18 @@ func newMemoryStore() *memoryStore {
return &memoryStore{
activations: make(map[string]Activation),
inventory: make(map[string][]InventoryItem),
- bridges: make(map[string]bridgepkg.BridgeInstance),
}
}
+func copyStringMap(values map[string]string) map[string]string {
+ if len(values) == 0 {
+ return nil
+ }
+ next := make(map[string]string, len(values))
+ maps.Copy(next, values)
+ return next
+}
+
func (s *memoryStore) CreateBundleActivation(_ context.Context, activation Activation) error {
if s.createBundleActivationHook != nil {
if err := s.createBundleActivationHook(activation); err != nil {
@@ -89,68 +107,62 @@ func (s *memoryStore) ListBundleActivations(_ context.Context) ([]Activation, er
return items, nil
}
-func (s *memoryStore) ReplaceBundleActivationInventory(
- _ context.Context,
- activationID string,
- items []InventoryItem,
-) error {
- s.inventory[activationID] = append([]InventoryItem(nil), items...)
- return nil
-}
-
func (s *memoryStore) ListBundleActivationInventory(_ context.Context, activationID string) ([]InventoryItem, error) {
return append([]InventoryItem(nil), s.inventory[activationID]...), nil
}
-func (s *memoryStore) ListBridgeInstances(_ context.Context) ([]bridgepkg.BridgeInstance, error) {
- items := make([]bridgepkg.BridgeInstance, 0, len(s.bridges))
- for _, instance := range s.bridges {
- items = append(items, instance)
- }
- return items, nil
-}
-
-func (s *memoryStore) InsertBridgeInstance(_ context.Context, instance bridgepkg.BridgeInstance) error {
- s.bridges[instance.ID] = instance
- return nil
-}
-
-func (s *memoryStore) UpdateBridgeInstance(_ context.Context, instance bridgepkg.BridgeInstance) error {
- s.bridges[instance.ID] = instance
- return nil
-}
-
-func (s *memoryStore) DeleteBridgeInstance(_ context.Context, id string) error {
- delete(s.bridges, id)
- return nil
-}
-
-type recordingAutomationSyncer struct {
- source automationpkg.JobSource
- jobs []automationpkg.Job
- triggers []automationpkg.Trigger
- calls int
- err error
+func (s *memoryStore) ListBundleResources(
+ _ context.Context,
+) ([]resources.Record[BundleResourceSpec], error) {
+ return append([]resources.Record[BundleResourceSpec](nil), s.bundles...), nil
}
-func (s *recordingAutomationSyncer) SyncManagedDefinitions(
+func (s *memoryStore) ApplyBundleActivationResources(
_ context.Context,
- source automationpkg.JobSource,
- desiredJobs []automationpkg.Job,
- desiredTriggers []automationpkg.Trigger,
- _ map[string]string,
-) (automationpkg.SyncStats, error) {
- s.calls++
- s.source = source
- s.jobs = append([]automationpkg.Job(nil), desiredJobs...)
- s.triggers = append([]automationpkg.Trigger(nil), desiredTriggers...)
- if s.err != nil {
- return automationpkg.SyncStats{}, s.err
- }
- return automationpkg.SyncStats{
- JobsSynced: len(desiredJobs),
- TriggersSynced: len(desiredTriggers),
- }, nil
+ plan BundleActivationResourcePlan,
+) error {
+ if s.applyErr != nil {
+ return s.applyErr
+ }
+ next := plan
+ next.activeActivationIDs = cloneStringSet(plan.activeActivationIDs)
+ next.desiredJobs = cloneJobsForBundle(plan.desiredJobs)
+ next.desiredTriggers = cloneTriggersForBundle(plan.desiredTriggers)
+ next.desiredBridges = cloneBridgeInstancesForBundle(plan.desiredBridges)
+ next.jobOwners = copyStringMap(plan.jobOwners)
+ next.triggerOwners = copyStringMap(plan.triggerOwners)
+ next.bridgeOwners = copyStringMap(plan.bridgeOwners)
+ next.declaredChannels = append([]DeclaredChannel(nil), plan.declaredChannels...)
+ s.applied = append(s.applied, next)
+ s.inventory = make(map[string][]InventoryItem)
+ for _, job := range plan.desiredJobs {
+ activationID := strings.TrimSpace(plan.jobOwners[strings.TrimSpace(job.ID)])
+ s.inventory[activationID] = append(s.inventory[activationID], InventoryItem{
+ ActivationID: activationID,
+ ResourceKind: string(automationpkg.JobResourceKind),
+ ResourceID: job.ID,
+ ResourceName: job.Name,
+ })
+ }
+ for _, trigger := range plan.desiredTriggers {
+ activationID := strings.TrimSpace(plan.triggerOwners[strings.TrimSpace(trigger.ID)])
+ s.inventory[activationID] = append(s.inventory[activationID], InventoryItem{
+ ActivationID: activationID,
+ ResourceKind: string(automationpkg.TriggerResourceKind),
+ ResourceID: trigger.ID,
+ ResourceName: trigger.Name,
+ })
+ }
+ for _, instance := range plan.desiredBridges {
+ activationID := strings.TrimSpace(plan.bridgeOwners[strings.TrimSpace(instance.ID)])
+ s.inventory[activationID] = append(s.inventory[activationID], InventoryItem{
+ ActivationID: activationID,
+ ResourceKind: string(bridgepkg.BridgeInstanceResourceKind),
+ ResourceID: instance.ID,
+ ResourceName: instance.DisplayName,
+ })
+ }
+ return nil
}
type staticExtensionLister struct {
@@ -239,13 +251,20 @@ func newMarketingExtension() *extensionpkg.Extension {
}
}
-func newMarketingService(store *memoryStore, automation *recordingAutomationSyncer, opts ...Option) *Service {
+func newMarketingService(store *memoryStore, opts ...Option) *Service {
ext := newMarketingExtension()
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ OwnerBridgePlatform: ext.Manifest.Bridge.Platform,
+ OwnerProvidesBridgeAdapter: true,
+ },
+ }}
options := []Option{
- WithAutomation(automation),
- WithBridges(bridgepkg.NewManagedSyncer(store, bridgepkg.WithManagedSyncNow(func() time.Time {
- return time.Date(2026, 4, 14, 22, 0, 0, 0, time.UTC)
- }))),
WithConfiguredDefaultChannel("default"),
WithNow(func() time.Time {
return time.Date(2026, 4, 14, 22, 0, 0, 0, time.UTC)
@@ -269,8 +288,7 @@ func TestServiceActivateMaterializesManagedResources(t *testing.T) {
t.Parallel()
store := newMemoryStore()
- automation := &recordingAutomationSyncer{}
- service := newMarketingService(store, automation)
+ service := newMarketingService(store)
preview, err := service.Activate(testutil.Context(t), ActivateRequest{
ExtensionName: "marketing-team",
@@ -286,17 +304,21 @@ func TestServiceActivateMaterializesManagedResources(t *testing.T) {
if got, want := len(preview.Inventory), 3; got != want {
t.Fatalf("len(preview.Inventory) = %d, want %d", got, want)
}
- if got, want := automation.source, automationpkg.JobSourcePackage; got != want {
- t.Fatalf("automation source = %q, want %q", got, want)
+ if got, want := len(store.applied), 1; got != want {
+ t.Fatalf("len(applied plans) = %d, want %d", got, want)
}
- if got, want := len(automation.jobs), 1; got != want {
- t.Fatalf("len(automation.jobs) = %d, want %d", got, want)
+ plan := store.applied[0]
+ if got, want := len(plan.desiredJobs), 1; got != want {
+ t.Fatalf("len(plan.desiredJobs) = %d, want %d", got, want)
}
- if got, want := len(automation.triggers), 1; got != want {
- t.Fatalf("len(automation.triggers) = %d, want %d", got, want)
+ if got, want := plan.desiredJobs[0].Source, automationpkg.JobSourcePackage; got != want {
+ t.Fatalf("plan.desiredJobs[0].Source = %q, want %q", got, want)
}
- if got, want := len(store.bridges), 1; got != want {
- t.Fatalf("len(store.bridges) = %d, want %d", got, want)
+ if got, want := len(plan.desiredTriggers), 1; got != want {
+ t.Fatalf("len(plan.desiredTriggers) = %d, want %d", got, want)
+ }
+ if got, want := len(plan.desiredBridges), 1; got != want {
+ t.Fatalf("len(plan.desiredBridges) = %d, want %d", got, want)
}
if strings.TrimSpace(preview.Activation.SpecContentHash) == "" {
t.Fatal("preview.Activation.SpecContentHash = empty, want persisted hash")
@@ -317,6 +339,76 @@ func TestServiceActivateMaterializesManagedResources(t *testing.T) {
}
}
+func TestServiceCatalogPreviewListAndGetUseCanonicalResources(t *testing.T) {
+ t.Parallel()
+
+ store := newMemoryStore()
+ service := newMarketingService(store, WithLogger(discardBundleTestLogger()))
+
+ catalog, err := service.Catalog(testutil.Context(t))
+ if err != nil {
+ t.Fatalf("Catalog() error = %v", err)
+ }
+ if got, want := len(catalog), 1; got != want {
+ t.Fatalf("len(Catalog()) = %d, want %d", got, want)
+ }
+ preview, err := service.PreviewActivation(testutil.Context(t), ActivateRequest{
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ })
+ if err != nil {
+ t.Fatalf("PreviewActivation() error = %v", err)
+ }
+ if got, want := len(preview.Inventory), 3; got != want {
+ t.Fatalf("len(preview.Inventory) = %d, want %d", got, want)
+ }
+ activated, err := service.Activate(testutil.Context(t), ActivateRequest{
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ })
+ if err != nil {
+ t.Fatalf("Activate() error = %v", err)
+ }
+ listed, err := service.ListActivations(testutil.Context(t))
+ if err != nil {
+ t.Fatalf("ListActivations() error = %v", err)
+ }
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(ListActivations()) = %d, want %d", got, want)
+ }
+ loaded, err := service.GetActivation(testutil.Context(t), activated.Activation.ID)
+ if err != nil {
+ t.Fatalf("GetActivation() error = %v", err)
+ }
+ if got, want := len(loaded.Inventory), 3; got != want {
+ t.Fatalf("len(GetActivation().Inventory) = %d, want %d", got, want)
+ }
+}
+
+func TestServiceReadMethodsValidateInputs(t *testing.T) {
+ t.Parallel()
+
+ var nilService *Service
+ if _, err := nilService.NetworkSettings(testutil.Context(t)); err == nil {
+ t.Fatal("nil NetworkSettings() error = nil, want failure")
+ }
+ service := newMarketingService(newMemoryStore())
+ var nilCtx context.Context
+ if _, err := service.NetworkSettings(nilCtx); err == nil {
+ t.Fatal("NetworkSettings(nil) error = nil, want failure")
+ }
+ if _, err := service.GetActivation(testutil.Context(t), ""); err == nil {
+ t.Fatal("GetActivation(empty) error = nil, want failure")
+ }
+ if _, err := service.ListActivations(nilCtx); err == nil {
+ t.Fatal("ListActivations(nil) error = nil, want failure")
+ }
+}
+
func TestServiceRejectsMultipleDefaultChannelClaims(t *testing.T) {
t.Parallel()
@@ -343,6 +435,15 @@ func TestServiceRejectsMultipleDefaultChannelClaims(t *testing.T) {
},
}},
}
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ },
+ }}
service := NewService(
store,
@@ -381,8 +482,7 @@ func TestServiceDeactivateCleansUpManagedResources(t *testing.T) {
t.Parallel()
store := newMemoryStore()
- automation := &recordingAutomationSyncer{}
- service := newMarketingService(store, automation)
+ service := newMarketingService(store)
preview, err := service.Activate(testutil.Context(t), ActivateRequest{
ExtensionName: "marketing-team",
@@ -405,17 +505,21 @@ func TestServiceDeactivateCleansUpManagedResources(t *testing.T) {
if got := len(store.inventory); got != 0 {
t.Fatalf("len(store.inventory) = %d, want 0", got)
}
- if got := len(store.bridges); got != 0 {
- t.Fatalf("len(store.bridges) = %d, want 0", got)
+ if got, want := len(store.applied), 2; got != want {
+ t.Fatalf("len(applied plans) = %d, want %d", got, want)
+ }
+ last := store.applied[len(store.applied)-1]
+ if got := len(last.activeActivationIDs); got != 0 {
+ t.Fatalf("len(last.activeActivationIDs) = %d, want 0", got)
}
- if got, want := automation.calls, 2; got != want {
- t.Fatalf("automation calls = %d, want %d", got, want)
+ if got := len(last.desiredJobs); got != 0 {
+ t.Fatalf("len(last.desiredJobs) after deactivate = %d, want 0", got)
}
- if got := len(automation.jobs); got != 0 {
- t.Fatalf("len(automation.jobs) after deactivate = %d, want 0", got)
+ if got := len(last.desiredTriggers); got != 0 {
+ t.Fatalf("len(last.desiredTriggers) after deactivate = %d, want 0", got)
}
- if got := len(automation.triggers); got != 0 {
- t.Fatalf("len(automation.triggers) after deactivate = %d, want 0", got)
+ if got := len(last.desiredBridges); got != 0 {
+ t.Fatalf("len(last.desiredBridges) after deactivate = %d, want 0", got)
}
}
@@ -423,8 +527,7 @@ func TestServiceUpdateActivationRestoresRecordOnReconcileFailure(t *testing.T) {
t.Parallel()
store := newMemoryStore()
- automation := &recordingAutomationSyncer{}
- service := newMarketingService(store, automation)
+ service := newMarketingService(store)
preview, err := service.Activate(testutil.Context(t), ActivateRequest{
ExtensionName: "marketing-team",
@@ -438,7 +541,7 @@ func TestServiceUpdateActivationRestoresRecordOnReconcileFailure(t *testing.T) {
}
syncErr := errors.New("sync failed")
- automation.err = syncErr
+ store.applyErr = syncErr
_, err = service.UpdateActivation(testutil.Context(t), UpdateActivationRequest{
ID: preview.Activation.ID,
BindPrimaryChannelAsDefault: true,
@@ -460,8 +563,7 @@ func TestServiceDeactivateReturnsRollbackFailureWhenRestoreFails(t *testing.T) {
t.Parallel()
store := newMemoryStore()
- automation := &recordingAutomationSyncer{}
- service := newMarketingService(store, automation)
+ service := newMarketingService(store)
preview, err := service.Activate(testutil.Context(t), ActivateRequest{
ExtensionName: "marketing-team",
@@ -476,7 +578,7 @@ func TestServiceDeactivateReturnsRollbackFailureWhenRestoreFails(t *testing.T) {
syncErr := errors.New("sync failed")
recreateErr := errors.New("recreate failed")
- automation.err = syncErr
+ store.applyErr = syncErr
store.createBundleActivationHook = func(activation Activation) error {
if activation.ID == preview.Activation.ID {
return recreateErr
@@ -516,48 +618,161 @@ func TestServiceReconcileReturnsBeforeSyncWhenActivationResolutionFails(t *testi
}
store.activations[goodActivation.ID] = goodActivation
store.activations[badActivation.ID] = badActivation
- store.bridges[stableID("bri", badActivation.ID, "telegram-main")] = bridgepkg.BridgeInstance{
- ID: stableID("bri", badActivation.ID, "telegram-main"),
- Scope: bridgepkg.ScopeGlobal,
- Platform: "telegram",
- ExtensionName: "broken-team",
- DisplayName: "Broken Bridge",
- Source: bridgepkg.BridgeInstanceSourcePackage,
- Status: bridgepkg.BridgeStatusDisabled,
- RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
- }
-
- loadErr := errors.New("load failed")
- automation := &recordingAutomationSyncer{}
+ marketing := newMarketingExtension()
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(marketing.Info.Name, marketing.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: marketing.Info.Name,
+ Bundle: marketing.Bundles[0],
+ OwnerBridgePlatform: marketing.Manifest.Bridge.Platform,
+ OwnerProvidesBridgeAdapter: true,
+ },
+ }}
service := NewService(
store,
staticExtensionLister{items: []extensionpkg.ExtensionInfo{{Name: "marketing-team"}, {Name: "broken-team"}}},
func(name string) (*extensionpkg.Extension, error) {
switch name {
case "marketing-team":
- return newMarketingExtension(), nil
- case "broken-team":
- return nil, loadErr
+ return marketing, nil
default:
return nil, extensionpkg.ErrExtensionNotFound
}
},
- WithAutomation(automation),
- WithBridges(bridgepkg.NewManagedSyncer(store)),
)
err := service.Reconcile(testutil.Context(t))
- if !errors.Is(err, loadErr) {
- t.Fatalf("Reconcile() error = %v, want load failure", err)
+ if !errors.Is(err, ErrBundleNotFound) {
+ t.Fatalf("Reconcile() error = %v, want ErrBundleNotFound", err)
+ }
+ if got := len(store.applied); got != 0 {
+ t.Fatalf("len(applied plans) = %d, want 0 after failed resolve", got)
+ }
+}
+
+func TestServicePreviewRejectsWebhookTriggers(t *testing.T) {
+ t.Parallel()
+
+ store := newMemoryStore()
+ ext := &extensionpkg.Extension{
+ Info: extensionpkg.ExtensionInfo{Name: "webhook-team"},
+ Bundles: []extensionpkg.BundleSpec{{
+ Name: "webhook",
+ Profiles: []extensionpkg.BundleProfile{{
+ Name: "default",
+ Triggers: []extensionpkg.BundleTrigger{{
+ Name: "webhook",
+ AgentName: "planner",
+ Prompt: "Handle webhook",
+ Event: "webhook",
+ Enabled: true,
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ }},
+ }},
+ }},
+ }
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(ext.Info.Name, ext.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: ext.Info.Name,
+ Bundle: ext.Bundles[0],
+ },
+ }}
+ service := NewService(
+ store,
+ staticExtensionLister{items: []extensionpkg.ExtensionInfo{{Name: ext.Info.Name}}},
+ func(name string) (*extensionpkg.Extension, error) {
+ if name != ext.Info.Name {
+ return nil, extensionpkg.ErrExtensionNotFound
+ }
+ return ext, nil
+ },
+ )
+
+ _, err := service.PreviewActivation(testutil.Context(t), ActivateRequest{
+ ExtensionName: ext.Info.Name,
+ BundleName: "webhook",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ })
+ if !errors.Is(err, ErrWebhookUnsupported) {
+ t.Fatalf("PreviewActivation() error = %v, want ErrWebhookUnsupported", err)
+ }
+}
+
+func TestServiceMaterializesExternalBridgeProviderPlatform(t *testing.T) {
+ t.Parallel()
+
+ store := newMemoryStore()
+ consumer := &extensionpkg.Extension{
+ Info: extensionpkg.ExtensionInfo{Name: "consumer-team"},
+ Bundles: []extensionpkg.BundleSpec{{
+ Name: "consumer",
+ Profiles: []extensionpkg.BundleProfile{{
+ Name: "default",
+ Bridges: []extensionpkg.BundleBridgePreset{{
+ Name: "external",
+ ExtensionName: "provider-team",
+ DisplayName: "Provider Bridge",
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ }},
+ }},
+ }},
+ }
+ provider := &extensionpkg.Extension{
+ Info: extensionpkg.ExtensionInfo{Name: "provider-team"},
+ Manifest: &extensionpkg.Manifest{
+ Name: "provider-team",
+ Bridge: extensionpkg.BridgeConfig{
+ Platform: "slack",
+ DisplayName: "Slack",
+ },
+ },
}
- if got, want := automation.calls, 0; got != want {
- t.Fatalf("automation calls = %d, want %d", got, want)
+ store.bundles = []resources.Record[BundleResourceSpec]{{
+ Kind: BundleResourceKind,
+ ID: BundleResourceID(consumer.Info.Name, consumer.Bundles[0].Name),
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: BundleResourceSpec{
+ ExtensionName: consumer.Info.Name,
+ Bundle: consumer.Bundles[0],
+ },
+ }}
+ service := NewService(
+ store,
+ staticExtensionLister{items: []extensionpkg.ExtensionInfo{{Name: consumer.Info.Name}}},
+ func(name string) (*extensionpkg.Extension, error) {
+ switch name {
+ case consumer.Info.Name:
+ return consumer, nil
+ case provider.Info.Name:
+ return provider, nil
+ default:
+ return nil, extensionpkg.ErrExtensionNotFound
+ }
+ },
+ )
+
+ preview, err := service.PreviewActivation(testutil.Context(t), ActivateRequest{
+ ExtensionName: consumer.Info.Name,
+ BundleName: "consumer",
+ ProfileName: "default",
+ Scope: ScopeGlobal,
+ })
+ if err != nil {
+ t.Fatalf("PreviewActivation() error = %v", err)
}
- if got, want := len(store.bridges), 1; got != want {
- t.Fatalf("len(store.bridges) = %d, want %d", got, want)
+ if got, want := len(preview.Inventory), 1; got != want {
+ t.Fatalf("len(preview.Inventory) = %d, want %d", got, want)
}
- if _, ok := store.bridges[stableID("bri", badActivation.ID, "telegram-main")]; !ok {
- t.Fatal("managed bridge for unresolved activation was removed, want preserved state after failed reconcile")
+ plan := store.applied
+ if len(plan) != 0 {
+ t.Fatalf("preview applied plans = %d, want 0", len(plan))
}
}
@@ -565,7 +780,6 @@ func TestServiceActivateWorkspaceScopedResources(t *testing.T) {
t.Parallel()
store := newMemoryStore()
- automation := &recordingAutomationSyncer{}
resolver := memoryWorkspaceResolver{
resolveFn: func(_ context.Context, idOrPath string) (workspacepkg.ResolvedWorkspace, error) {
return workspacepkg.ResolvedWorkspace{
@@ -577,7 +791,7 @@ func TestServiceActivateWorkspaceScopedResources(t *testing.T) {
}, nil
},
}
- service := newMarketingService(store, automation, WithWorkspaceResolver(resolver))
+ service := newMarketingService(store, WithWorkspaceResolver(resolver))
preview, err := service.Activate(testutil.Context(t), ActivateRequest{
ExtensionName: "marketing-team",
@@ -594,17 +808,15 @@ func TestServiceActivateWorkspaceScopedResources(t *testing.T) {
if got, want := preview.Activation.WorkspaceID, "ws-marketing"; got != want {
t.Fatalf("Activation.WorkspaceID = %q, want %q", got, want)
}
- if got, want := automation.jobs[0].Scope, automationpkg.AutomationScopeWorkspace; got != want {
+ plan := store.applied[0]
+ if got, want := plan.desiredJobs[0].Scope, automationpkg.AutomationScopeWorkspace; got != want {
t.Fatalf("job scope = %q, want %q", got, want)
}
- if got, want := automation.jobs[0].WorkspaceID, "ws-marketing"; got != want {
+ if got, want := plan.desiredJobs[0].WorkspaceID, "ws-marketing"; got != want {
t.Fatalf("job workspace = %q, want %q", got, want)
}
- var storedBridge bridgepkg.BridgeInstance
- for _, instance := range store.bridges {
- storedBridge = instance
- }
+ storedBridge := plan.desiredBridges[0]
if got, want := storedBridge.Scope, bridgepkg.ScopeWorkspace; got != want {
t.Fatalf("bridge scope = %q, want %q", got, want)
}
@@ -612,3 +824,40 @@ func TestServiceActivateWorkspaceScopedResources(t *testing.T) {
t.Fatalf("bridge workspace = %q, want %q", got, want)
}
}
+
+func TestServiceActivateWorkspacePathUsesResolveOrRegister(t *testing.T) {
+ t.Parallel()
+
+ store := newMemoryStore()
+ called := false
+ resolver := memoryWorkspaceResolver{
+ resolveOrRegisterFn: func(_ context.Context, path string) (workspacepkg.ResolvedWorkspace, error) {
+ called = true
+ return workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{
+ ID: "ws-path",
+ RootDir: path,
+ Name: "path-workspace",
+ },
+ }, nil
+ },
+ }
+ service := newMarketingService(store, WithWorkspaceResolver(resolver))
+
+ preview, err := service.Activate(testutil.Context(t), ActivateRequest{
+ ExtensionName: "marketing-team",
+ BundleName: "marketing",
+ ProfileName: "default",
+ Scope: ScopeWorkspace,
+ Workspace: filepath.Join(t.TempDir(), "workspace"),
+ })
+ if err != nil {
+ t.Fatalf("Activate(path workspace) error = %v", err)
+ }
+ if !called {
+ t.Fatal("ResolveOrRegister was not called for path-like workspace ref")
+ }
+ if got, want := preview.Activation.WorkspaceID, "ws-path"; got != want {
+ t.Fatalf("WorkspaceID = %q, want %q", got, want)
+ }
+}
diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go
index c68c60373..6492e28ca 100644
--- a/internal/cli/cli_integration_test.go
+++ b/internal/cli/cli_integration_test.go
@@ -26,6 +26,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
aghconfig "github.com/pedronauck/agh/internal/config"
aghdaemon "github.com/pedronauck/agh/internal/daemon"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
extensionpkg "github.com/pedronauck/agh/internal/extension"
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/network"
@@ -161,7 +162,7 @@ func TestSessionListOutputFormatsIntegration(t *testing.T) {
if err != nil {
t.Fatalf("session list toon error = %v", err)
}
- if !strings.Contains(toonOut, "sessions[1]{id,name,agent_name,state,workspace,channel,updated_at}:") {
+ if !strings.Contains(toonOut, "sessions[1]{id,name,agent_name,environment_backend,state,workspace,channel,updated_at}:") {
t.Fatalf("toon output = %q, want TOON table", toonOut)
}
}
@@ -1558,6 +1559,10 @@ func (d *integrationDaemon) Run(ctx context.Context) error {
if err != nil {
return fmt.Errorf("new workspace resolver: %w", err)
}
+ environmentRegistry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ return fmt.Errorf("new local environment registry: %w", err)
+ }
manager, err := session.NewManager(
session.WithHomePaths(d.homePaths),
session.WithWorkspaceResolver(resolver),
@@ -1570,6 +1575,7 @@ func (d *integrationDaemon) Run(ctx context.Context) error {
return driver
}()),
session.WithNotifier(fanout),
+ session.WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
return fmt.Errorf("new session manager: %w", err)
diff --git a/internal/cli/session.go b/internal/cli/session.go
index 896db4779..5e7d862f6 100644
--- a/internal/cli/session.go
+++ b/internal/cli/session.go
@@ -417,21 +417,34 @@ func sessionBundle(info SessionRecord, now func() time.Time) outputBundle {
{Label: "Age", Value: stringOrDash(formatAge(now, info.CreatedAt))},
})
+ blocks := []string{base}
+ if info.Environment != nil {
+ blocks = append(blocks, renderHumanSection("Environment", []keyValue{
+ {Label: "Backend", Value: stringOrDash(info.Environment.Backend)},
+ {Label: "Profile", Value: stringOrDash(info.Environment.Profile)},
+ {Label: "Environment ID", Value: stringOrDash(info.Environment.EnvironmentID)},
+ {Label: "Instance ID", Value: stringOrDash(info.Environment.InstanceID)},
+ {Label: "State", Value: stringOrDash(info.Environment.State)},
+ {Label: "Last Sync Error", Value: stringOrDash(info.Environment.LastSyncError)},
+ }))
+ }
if info.ACPCaps == nil {
- return base, nil
+ return renderHumanBlocks(blocks...), nil
}
caps := renderHumanSection("Capabilities", []keyValue{
{Label: "Supports Load", Value: strconv.FormatBool(info.ACPCaps.SupportsLoadSession)},
{Label: "Modes", Value: stringOrDash(strings.Join(info.ACPCaps.SupportedModes, ", "))},
{Label: "Models", Value: stringOrDash(strings.Join(info.ACPCaps.SupportedModels, ", "))},
})
- return renderHumanBlocks(base, caps), nil
+ blocks = append(blocks, caps)
+ return renderHumanBlocks(blocks...), nil
},
toon: func() (string, error) {
return renderToonObject("session", []string{
"id",
"name",
"agent_name",
+ "environment_backend",
"workspace",
"channel",
"state",
@@ -442,6 +455,7 @@ func sessionBundle(info SessionRecord, now func() time.Time) outputBundle {
info.ID,
info.Name,
info.AgentName,
+ sessionEnvironmentBackend(info),
displaySessionWorkspace(info),
info.Channel,
string(info.State),
@@ -458,14 +472,15 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle
items,
items,
"Sessions",
- []string{"ID", "Name", "Agent", "State", "Workspace", "Channel", "Updated"},
+ []string{"ID", "Name", "Agent", "Backend", "State", "Workspace", "Channel", "Updated"},
"sessions",
- []string{"id", "name", "agent_name", "state", "workspace", "channel", "updated_at"},
+ []string{"id", "name", "agent_name", "environment_backend", "state", "workspace", "channel", "updated_at"},
func(item SessionRecord) []string {
return []string{
stringOrDash(item.ID),
stringOrDash(item.Name),
stringOrDash(item.AgentName),
+ stringOrDash(sessionEnvironmentBackend(item)),
stringOrDash(string(item.State)),
stringOrDash(displaySessionWorkspace(item)),
stringOrDash(item.Channel),
@@ -477,6 +492,7 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle
item.ID,
item.Name,
item.AgentName,
+ sessionEnvironmentBackend(item),
string(item.State),
displaySessionWorkspace(item),
item.Channel,
@@ -486,6 +502,13 @@ func sessionListBundle(items []SessionRecord, now func() time.Time) outputBundle
)
}
+func sessionEnvironmentBackend(info SessionRecord) string {
+ if info.Environment == nil {
+ return ""
+ }
+ return strings.TrimSpace(info.Environment.Backend)
+}
+
func sessionEventsBundle(events []SessionEventRecord) outputBundle {
return listBundle(
events,
diff --git a/internal/cli/workspace.go b/internal/cli/workspace.go
index 46b1f101c..7daf87ef8 100644
--- a/internal/cli/workspace.go
+++ b/internal/cli/workspace.go
@@ -25,9 +25,10 @@ func newWorkspaceCommand(deps commandDeps) *cobra.Command {
func newWorkspaceAddCommand(deps commandDeps) *cobra.Command {
var (
- name string
- addDirs []string
- defaultAgent string
+ name string
+ addDirs []string
+ defaultAgent string
+ environmentRef string
)
cmd := &cobra.Command{
@@ -46,10 +47,11 @@ func newWorkspaceAddCommand(deps commandDeps) *cobra.Command {
}
workspace, err := client.CreateWorkspace(cmd.Context(), WorkspaceCreateRequest{
- RootDir: strings.TrimSpace(args[0]),
- Name: strings.TrimSpace(name),
- AddDirs: trimmedUniqueStrings(addDirs),
- DefaultAgent: strings.TrimSpace(defaultAgent),
+ RootDir: strings.TrimSpace(args[0]),
+ Name: strings.TrimSpace(name),
+ AddDirs: trimmedUniqueStrings(addDirs),
+ DefaultAgent: strings.TrimSpace(defaultAgent),
+ EnvironmentRef: strings.TrimSpace(environmentRef),
})
if err != nil {
return err
@@ -63,6 +65,8 @@ func newWorkspaceAddCommand(deps commandDeps) *cobra.Command {
StringArrayVar(&addDirs, "add-dir", nil, "Additional directory to include (repeatable)")
cmd.Flags().
StringVar(&defaultAgent, "default-agent", "", "Default agent override for this workspace")
+ cmd.Flags().
+ StringVar(&environmentRef, "environment", "", "Environment profile override for this workspace")
return cmd
}
@@ -115,12 +119,21 @@ func newWorkspaceInfoCommand(deps commandDeps) *cobra.Command {
}
}
+func workspaceEditFlagsChanged(cmd *cobra.Command) bool {
+ return cmd.Flags().Changed("name") ||
+ cmd.Flags().Changed("add-dir") ||
+ cmd.Flags().Changed("remove-dir") ||
+ cmd.Flags().Changed("default-agent") ||
+ cmd.Flags().Changed("environment")
+}
+
func newWorkspaceEditCommand(deps commandDeps) *cobra.Command {
var (
- name string
- addDirs []string
- removeDirs []string
- defaultAgent string
+ name string
+ addDirs []string
+ removeDirs []string
+ defaultAgent string
+ environmentRef string
)
cmd := &cobra.Command{
@@ -138,11 +151,7 @@ func newWorkspaceEditCommand(deps commandDeps) *cobra.Command {
return err
}
- nameChanged := cmd.Flags().Changed("name")
- addChanged := cmd.Flags().Changed("add-dir")
- removeChanged := cmd.Flags().Changed("remove-dir")
- defaultAgentChanged := cmd.Flags().Changed("default-agent")
- if !nameChanged && !addChanged && !removeChanged && !defaultAgentChanged {
+ if !workspaceEditFlagsChanged(cmd) {
return errors.New("cli: at least one edit flag is required")
}
@@ -152,14 +161,14 @@ func newWorkspaceEditCommand(deps commandDeps) *cobra.Command {
}
request := WorkspaceUpdateRequest{}
- if nameChanged {
+ if cmd.Flags().Changed("name") {
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return errors.New("cli: --name cannot be empty")
}
request.Name = &trimmedName
}
- if addChanged || removeChanged {
+ if cmd.Flags().Changed("add-dir") || cmd.Flags().Changed("remove-dir") {
mergedDirs, err := mergeWorkspaceAddDirs(
detail.Workspace.AddDirs,
addDirs,
@@ -170,10 +179,14 @@ func newWorkspaceEditCommand(deps commandDeps) *cobra.Command {
}
request.AddDirs = &mergedDirs
}
- if defaultAgentChanged {
+ if cmd.Flags().Changed("default-agent") {
trimmedDefaultAgent := strings.TrimSpace(defaultAgent)
request.DefaultAgent = &trimmedDefaultAgent
}
+ if cmd.Flags().Changed("environment") {
+ trimmedEnvironment := strings.TrimSpace(environmentRef)
+ request.EnvironmentRef = &trimmedEnvironment
+ }
updated, err := client.UpdateWorkspace(cmd.Context(), detail.Workspace.ID, request)
if err != nil {
@@ -190,6 +203,8 @@ func newWorkspaceEditCommand(deps commandDeps) *cobra.Command {
StringArrayVar(&removeDirs, "remove-dir", nil, "Additional directory to remove (repeatable)")
cmd.Flags().
StringVar(&defaultAgent, "default-agent", "", "Override the workspace default agent (set empty to clear)")
+ cmd.Flags().
+ StringVar(&environmentRef, "environment", "", "Override the workspace environment profile (set empty to clear)")
return cmd
}
@@ -229,19 +244,21 @@ func workspaceRecordBundle(item WorkspaceRecord) outputBundle {
{Label: "Root", Value: stringOrDash(item.RootDir)},
{Label: "Additional Dirs", Value: stringOrDash(strings.Join(item.AddDirs, ", "))},
{Label: "Default Agent", Value: stringOrDash(item.DefaultAgent)},
+ {Label: "Environment", Value: stringOrDash(item.EnvironmentRef)},
{Label: "Created", Value: stringOrDash(formatTime(item.CreatedAt))},
{Label: "Updated", Value: stringOrDash(formatTime(item.UpdatedAt))},
}), nil
},
toon: func() (string, error) {
return renderToonObject("workspace", []string{
- "id", "name", "root_dir", "add_dirs", "default_agent", "created_at", "updated_at",
+ "id", "name", "root_dir", "add_dirs", "default_agent", "environment_ref", "created_at", "updated_at",
}, []string{
item.ID,
item.Name,
item.RootDir,
strings.Join(item.AddDirs, "|"),
item.DefaultAgent,
+ item.EnvironmentRef,
formatTime(item.CreatedAt),
formatTime(item.UpdatedAt),
}), nil
@@ -254,9 +271,9 @@ func workspaceListBundle(items []WorkspaceRecord) outputBundle {
items,
items,
"Workspaces",
- []string{"ID", "Name", "Root", "Add Dirs", "Default Agent", "Updated"},
+ []string{"ID", "Name", "Root", "Add Dirs", "Default Agent", "Environment", "Updated"},
"workspaces",
- []string{"id", "name", "root_dir", "add_dir_count", "default_agent", "updated_at"},
+ []string{"id", "name", "root_dir", "add_dir_count", "default_agent", "environment_ref", "updated_at"},
func(item WorkspaceRecord) []string {
return []string{
stringOrDash(item.ID),
@@ -264,6 +281,7 @@ func workspaceListBundle(items []WorkspaceRecord) outputBundle {
stringOrDash(item.RootDir),
strconv.Itoa(len(item.AddDirs)),
stringOrDash(item.DefaultAgent),
+ stringOrDash(item.EnvironmentRef),
stringOrDash(formatTime(item.UpdatedAt)),
}
},
@@ -274,6 +292,7 @@ func workspaceListBundle(items []WorkspaceRecord) outputBundle {
item.RootDir,
strconv.Itoa(len(item.AddDirs)),
item.DefaultAgent,
+ item.EnvironmentRef,
formatTime(item.UpdatedAt),
}
},
diff --git a/internal/cli/workspace_test.go b/internal/cli/workspace_test.go
index d8976dee6..d4bf03c8b 100644
--- a/internal/cli/workspace_test.go
+++ b/internal/cli/workspace_test.go
@@ -30,13 +30,15 @@ func TestWorkspaceAddBuildsRequest(t *testing.T) {
"--add-dir", "/workspace/shared-a",
"--add-dir", "/workspace/shared-b",
"--default-agent", "coder",
+ "--environment", "daytona-dev",
"-o", "json",
},
request: WorkspaceCreateRequest{
- RootDir: "/workspace/project",
- Name: "alpha",
- AddDirs: []string{"/workspace/shared-a", "/workspace/shared-b"},
- DefaultAgent: "coder",
+ RootDir: "/workspace/project",
+ Name: "alpha",
+ AddDirs: []string{"/workspace/shared-a", "/workspace/shared-b"},
+ DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
},
},
}
@@ -48,20 +50,22 @@ func TestWorkspaceAddBuildsRequest(t *testing.T) {
deps := newTestDeps(t, &stubClient{
createWorkspaceFn: func(_ context.Context, request WorkspaceCreateRequest) (WorkspaceRecord, error) {
if request.RootDir != tt.request.RootDir || request.Name != tt.request.Name ||
- request.DefaultAgent != tt.request.DefaultAgent {
+ request.DefaultAgent != tt.request.DefaultAgent ||
+ request.EnvironmentRef != tt.request.EnvironmentRef {
t.Fatalf("CreateWorkspace() request = %#v, want %#v", request, tt.request)
}
if strings.Join(request.AddDirs, ",") != strings.Join(tt.request.AddDirs, ",") {
t.Fatalf("CreateWorkspace() AddDirs = %#v, want %#v", request.AddDirs, tt.request.AddDirs)
}
return WorkspaceRecord{
- ID: "ws_alpha",
- RootDir: request.RootDir,
- AddDirs: request.AddDirs,
- Name: firstNonEmpty(request.Name, "alpha"),
- DefaultAgent: request.DefaultAgent,
- CreatedAt: fixedTestNow,
- UpdatedAt: fixedTestNow,
+ ID: "ws_alpha",
+ RootDir: request.RootDir,
+ AddDirs: request.AddDirs,
+ Name: firstNonEmpty(request.Name, "alpha"),
+ DefaultAgent: request.DefaultAgent,
+ EnvironmentRef: request.EnvironmentRef,
+ CreatedAt: fixedTestNow,
+ UpdatedAt: fixedTestNow,
}, nil
},
})
@@ -105,13 +109,14 @@ func TestWorkspaceEditBuildsRequest(t *testing.T) {
seenRef = ref
seenRequest = request
return WorkspaceRecord{
- ID: "ws_alpha",
- RootDir: "/workspace/project",
- AddDirs: derefStringSlice(request.AddDirs),
- Name: derefString(request.Name),
- DefaultAgent: derefString(request.DefaultAgent),
- CreatedAt: fixedTestNow,
- UpdatedAt: fixedTestNow,
+ ID: "ws_alpha",
+ RootDir: "/workspace/project",
+ AddDirs: derefStringSlice(request.AddDirs),
+ Name: derefString(request.Name),
+ DefaultAgent: derefString(request.DefaultAgent),
+ EnvironmentRef: derefString(request.EnvironmentRef),
+ CreatedAt: fixedTestNow,
+ UpdatedAt: fixedTestNow,
}, nil
},
})
@@ -122,6 +127,7 @@ func TestWorkspaceEditBuildsRequest(t *testing.T) {
"--add-dir", "/workspace/shared-c",
"--remove-dir", "/workspace/shared-a",
"--default-agent", "reviewer",
+ "--environment", "local-dev",
"-o", "json",
)
if err != nil {
@@ -140,6 +146,9 @@ func TestWorkspaceEditBuildsRequest(t *testing.T) {
if seenRequest.DefaultAgent == nil || *seenRequest.DefaultAgent != "reviewer" {
t.Fatalf("UpdateWorkspace() DefaultAgent = %#v, want reviewer", seenRequest.DefaultAgent)
}
+ if seenRequest.EnvironmentRef == nil || *seenRequest.EnvironmentRef != "local-dev" {
+ t.Fatalf("UpdateWorkspace() EnvironmentRef = %#v, want local-dev", seenRequest.EnvironmentRef)
+ }
var decoded WorkspaceRecord
if err := json.Unmarshal([]byte(stdout), &decoded); err != nil {
@@ -331,7 +340,10 @@ func TestWorkspaceOutputFormats(t *testing.T) {
if err != nil {
t.Fatalf("executeRootCommand(workspace list toon) error = %v", err)
}
- if !strings.Contains(listToon, "workspaces[1]{id,name,root_dir,add_dir_count,default_agent,updated_at}:") {
+ if !strings.Contains(
+ listToon,
+ "workspaces[1]{id,name,root_dir,add_dir_count,default_agent,environment_ref,updated_at}:",
+ ) {
t.Fatalf("list toon output = %q, want TOON header", listToon)
}
diff --git a/internal/config/agent_resource.go b/internal/config/agent_resource.go
new file mode 100644
index 000000000..eb3b678d6
--- /dev/null
+++ b/internal/config/agent_resource.go
@@ -0,0 +1,72 @@
+package config
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ // AgentResourceKind is the canonical desired-state resource kind for agent definitions.
+ AgentResourceKind resources.ResourceKind = "agent"
+ agentResourceMaxBytes = 512 << 10
+)
+
+// NewAgentResourceCodec builds the canonical agent resource codec.
+func NewAgentResourceCodec() (resources.KindCodec[AgentDef], error) {
+ return resources.NewJSONCodec(AgentResourceKind, agentResourceMaxBytes, validateAgentResourceSpec)
+}
+
+func validateAgentResourceSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec AgentDef,
+) (AgentDef, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return AgentDef{}, err
+ }
+
+ normalized := AgentDef{
+ Name: strings.TrimSpace(spec.Name),
+ Provider: strings.TrimSpace(spec.Provider),
+ Command: strings.TrimSpace(spec.Command),
+ Model: strings.TrimSpace(spec.Model),
+ Tools: normalizeAgentToolRefs(spec.Tools),
+ Permissions: strings.TrimSpace(spec.Permissions),
+ MCPServers: cloneMCPServers(spec.MCPServers),
+ Hooks: cloneHookDecls(spec.Hooks),
+ Prompt: strings.TrimSpace(spec.Prompt),
+ }
+ for idx := range normalized.MCPServers {
+ normalized.MCPServers[idx].Name = strings.TrimSpace(normalized.MCPServers[idx].Name)
+ normalized.MCPServers[idx].Command = strings.TrimSpace(normalized.MCPServers[idx].Command)
+ }
+
+ if err := normalized.Validate(); err != nil {
+ return AgentDef{}, errors.Join(resources.ErrValidation, err)
+ }
+ return normalized, nil
+}
+
+func normalizeAgentToolRefs(values []string) []string {
+ refs := make([]string, 0, len(values))
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ refs = append(refs, trimmed)
+ }
+ if len(refs) == 0 {
+ return []string{"*"}
+ }
+ return refs
+}
diff --git a/internal/config/agent_resource_test.go b/internal/config/agent_resource_test.go
new file mode 100644
index 000000000..e0969fb52
--- /dev/null
+++ b/internal/config/agent_resource_test.go
@@ -0,0 +1,125 @@
+package config
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+func TestAgentResourceCodecRejectsInvalidSpecs(t *testing.T) {
+ t.Parallel()
+
+ codec, err := NewAgentResourceCodec()
+ if err != nil {
+ t.Fatalf("NewAgentResourceCodec() error = %v", err)
+ }
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+
+ tests := []struct {
+ name string
+ spec AgentDef
+ wantErr string
+ }{
+ {
+ name: "ShouldRejectMissingName",
+ spec: AgentDef{
+ Prompt: "You are helpful.",
+ },
+ wantErr: "agent name is required",
+ },
+ {
+ name: "ShouldRejectMissingPrompt",
+ spec: AgentDef{
+ Name: "coder",
+ },
+ wantErr: "agent prompt is required",
+ },
+ {
+ name: "ShouldRejectInvalidPermissions",
+ spec: AgentDef{
+ Name: "coder",
+ Prompt: "You are helpful.",
+ Permissions: "invalid",
+ },
+ wantErr: "agent.permissions",
+ },
+ {
+ name: "ShouldRejectInvalidMCPServer",
+ spec: AgentDef{
+ Name: "coder",
+ Prompt: "You are helpful.",
+ MCPServers: []MCPServer{{
+ Name: "github",
+ }},
+ },
+ wantErr: "agent.mcp_servers[0]",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ raw, err := codec.Encode(tt.spec)
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+ _, err = codec.DecodeAndValidate(context.Background(), scope, raw)
+ if err == nil {
+ t.Fatal("DecodeAndValidate() error = nil, want validation error")
+ }
+ if !errors.Is(err, resources.ErrValidation) {
+ t.Fatalf("DecodeAndValidate() error = %v, want resources.ErrValidation", err)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("DecodeAndValidate() error = %v, want %q", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestAgentResourceCodecCanonicalizesTypedRecordSpec(t *testing.T) {
+ t.Parallel()
+
+ codec, err := NewAgentResourceCodec()
+ if err != nil {
+ t.Fatalf("NewAgentResourceCodec() error = %v", err)
+ }
+ raw, err := codec.Encode(AgentDef{
+ Name: " coder ",
+ Prompt: " Build things. ",
+ Tools: []string{" github.search ", "", "github.search", " * "},
+ MCPServers: []MCPServer{{
+ Name: " github ",
+ Command: " npx ",
+ Args: []string{" -y "},
+ }},
+ })
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+
+ got, err := codec.DecodeAndValidate(
+ context.Background(),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws_1"},
+ raw,
+ )
+ if err != nil {
+ t.Fatalf("DecodeAndValidate() error = %v", err)
+ }
+ if got.Name != "coder" || got.Prompt != "Build things." {
+ t.Fatalf("decoded agent = %#v, want trimmed name and prompt", got)
+ }
+ if want := []string{"github.search", "*"}; strings.Join(got.Tools, ",") != strings.Join(want, ",") {
+ t.Fatalf("Tools = %#v, want %#v", got.Tools, want)
+ }
+ if gotCount, wantCount := len(got.MCPServers), 1; gotCount != wantCount {
+ t.Fatalf("len(MCPServers) = %d, want %d", gotCount, wantCount)
+ }
+ if got.MCPServers[0].Name != "github" || got.MCPServers[0].Command != "npx" {
+ t.Fatalf("MCPServers = %#v, want trimmed name/command", got.MCPServers)
+ }
+}
diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go
index 6212bf2b9..18b9514ca 100644
--- a/internal/config/bootstrap.go
+++ b/internal/config/bootstrap.go
@@ -81,7 +81,7 @@ func SaveBootstrapConfig(homePaths HomePaths, provider string, model string) (Co
return Config{}, fmt.Errorf("validate bootstrap config: %w", err)
}
- if err := writeConfigOverlayFile(homePaths.ConfigFile, overlay); err != nil {
+ if err := writeConfigOverlayFile(homePaths.ConfigFile, &overlay); err != nil {
return Config{}, err
}
return finalCfg, nil
@@ -122,7 +122,7 @@ func bootstrapAgentContents() string {
}, "\n")
}
-func writeConfigOverlayFile(path string, overlay configOverlay) error {
+func writeConfigOverlayFile(path string, overlay *configOverlay) error {
var buffer bytes.Buffer
if err := toml.NewEncoder(&buffer).Encode(overlay); err != nil {
return fmt.Errorf("encode config file %q: %w", path, err)
diff --git a/internal/config/config.go b/internal/config/config.go
index dbbb67936..e7926a21d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,6 +14,9 @@ import (
"github.com/joho/godotenv"
automationpkg "github.com/pedronauck/agh/internal/automation/model"
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/extension/surfaces"
+ "github.com/pedronauck/agh/internal/resources"
)
const (
@@ -38,8 +41,9 @@ type HTTPConfig struct {
// DefaultsConfig holds global runtime defaults.
type DefaultsConfig struct {
- Agent string `toml:"agent"`
- Provider string `toml:"provider,omitempty"`
+ Agent string `toml:"agent"`
+ Provider string `toml:"provider,omitempty"`
+ Environment string `toml:"environment,omitempty"`
}
// LimitsConfig defines runtime safety bounds.
@@ -135,6 +139,22 @@ type SkillsConfig struct {
// ExtensionsConfig controls extension marketplace discovery and install behavior.
type ExtensionsConfig struct {
Marketplace ExtensionsMarketplaceConfig `toml:"marketplace,omitempty"`
+ Resources ExtensionsResourcesConfig `toml:"resources,omitempty"`
+}
+
+// ExtensionsResourcesConfig controls resource publication policy for extensions.
+type ExtensionsResourcesConfig struct {
+ AllowedKinds []resources.ResourceKind `toml:"allowed_kinds,omitempty"`
+ MaxScope resources.ResourceScopeKind `toml:"max_scope,omitempty"`
+ SnapshotRateLimit ExtensionsResourceRateLimitConfig `toml:"snapshot_rate_limit,omitempty"`
+ OperatorWriteRateLimit ExtensionsResourceRateLimitConfig `toml:"operator_write_rate_limit,omitempty"`
+}
+
+// ExtensionsResourceRateLimitConfig controls one resource publication rate-limit bucket.
+type ExtensionsResourceRateLimitConfig struct {
+ Requests int `toml:"requests"`
+ Window time.Duration `toml:"window"`
+ Queue int `toml:"queue"`
}
// NetworkConfig controls the embedded AGH network runtime.
@@ -148,24 +168,56 @@ type NetworkConfig struct {
MaxQueueDepth int `toml:"max_queue_depth"`
}
+// EnvironmentProfile defines one reusable execution environment profile.
+type EnvironmentProfile struct {
+ Backend string `toml:"backend"`
+ SyncMode string `toml:"sync_mode,omitempty"`
+ Persistence string `toml:"persistence,omitempty"`
+ RuntimeRoot string `toml:"runtime_root,omitempty"`
+ Env map[string]string `toml:"env,omitempty"`
+ Network NetworkProfile `toml:"network,omitempty"`
+ Daytona DaytonaProfile `toml:"daytona,omitempty"`
+}
+
+// NetworkProfile defines provider-neutral network policy intent.
+type NetworkProfile struct {
+ AllowPublicIngress bool `toml:"allow_public_ingress,omitempty"`
+ AllowOutbound bool `toml:"allow_outbound,omitempty"`
+ AllowList []string `toml:"allow_list,omitempty"`
+ DenyList []string `toml:"deny_list,omitempty"`
+ Required bool `toml:"required,omitempty"`
+}
+
+// DaytonaProfile defines Daytona-specific execution environment settings.
+type DaytonaProfile struct {
+ APIURL string `toml:"api_url,omitempty"`
+ Target string `toml:"target,omitempty"`
+ Image string `toml:"image,omitempty"`
+ Snapshot string `toml:"snapshot,omitempty"`
+ Class string `toml:"class,omitempty"`
+ AutoStop string `toml:"auto_stop,omitempty"`
+ AutoArchive string `toml:"auto_archive,omitempty"`
+}
+
// Config is the fully merged AGH configuration.
type Config struct {
- Daemon DaemonConfig `toml:"daemon"`
- HTTP HTTPConfig `toml:"http"`
- Defaults DefaultsConfig `toml:"defaults"`
- Limits LimitsConfig `toml:"limits"`
- Session SessionConfig `toml:"session"`
- Permissions PermissionsConfig `toml:"permissions"`
- MCPServers []MCPServer `toml:"mcp_servers,omitempty"`
- Providers map[string]ProviderConfig `toml:"providers"`
- Observability ObservabilityConfig `toml:"observability"`
- Log LogConfig `toml:"log"`
- Memory MemoryConfig `toml:"memory"`
- Skills SkillsConfig `toml:"skills"`
- Extensions ExtensionsConfig `toml:"extensions"`
- Automation AutomationConfig `toml:"automation"`
- Hooks HooksConfig `toml:"hooks"`
- Network NetworkConfig `toml:"network"`
+ Daemon DaemonConfig `toml:"daemon"`
+ HTTP HTTPConfig `toml:"http"`
+ Defaults DefaultsConfig `toml:"defaults"`
+ Limits LimitsConfig `toml:"limits"`
+ Session SessionConfig `toml:"session"`
+ Permissions PermissionsConfig `toml:"permissions"`
+ MCPServers []MCPServer `toml:"mcp_servers,omitempty"`
+ Providers map[string]ProviderConfig `toml:"providers"`
+ Environments map[string]EnvironmentProfile `toml:"environments"`
+ Observability ObservabilityConfig `toml:"observability"`
+ Log LogConfig `toml:"log"`
+ Memory MemoryConfig `toml:"memory"`
+ Skills SkillsConfig `toml:"skills"`
+ Extensions ExtensionsConfig `toml:"extensions"`
+ Automation AutomationConfig `toml:"automation"`
+ Hooks HooksConfig `toml:"hooks"`
+ Network NetworkConfig `toml:"network"`
}
type loadOptions struct {
@@ -311,7 +363,8 @@ func DefaultWithHome(homePaths HomePaths) Config {
Permissions: PermissionsConfig{
Mode: PermissionModeApproveAll,
},
- Providers: map[string]ProviderConfig{},
+ Providers: map[string]ProviderConfig{},
+ Environments: map[string]EnvironmentProfile{},
Observability: ObservabilityConfig{
Enabled: true,
RetentionDays: 7,
@@ -373,6 +426,9 @@ func (c *Config) Validate() error {
if err := c.validateProviders(); err != nil {
return err
}
+ if err := c.validateEnvironments(); err != nil {
+ return err
+ }
return nil
}
@@ -446,6 +502,171 @@ func (c *Config) validateProviders() error {
return nil
}
+func (c *Config) validateEnvironments() error {
+ for name, profile := range c.Environments {
+ trimmedName := strings.TrimSpace(name)
+ if trimmedName == "" {
+ return errors.New("environments: profile name is required")
+ }
+ if err := profile.Validate(fmt.Sprintf("environments.%s", trimmedName)); err != nil {
+ return err
+ }
+ }
+ if ref := strings.TrimSpace(c.Defaults.Environment); ref != "" {
+ if _, err := c.ResolveEnvironment(ref); err != nil {
+ return fmt.Errorf("defaults.environment: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// ResolveEnvironment resolves a named environment profile into runtime policy.
+func (c *Config) ResolveEnvironment(ref string) (environment.Resolved, error) {
+ profileName := strings.TrimSpace(ref)
+ if profileName == "" {
+ profileName = string(environment.BackendLocal)
+ }
+
+ profile, ok := c.Environments[profileName]
+ if !ok {
+ if profileName == string(environment.BackendLocal) {
+ return defaultLocalEnvironment(), nil
+ }
+ return environment.Resolved{}, fmt.Errorf("environment profile %q not found", profileName)
+ }
+
+ resolved, err := profile.Resolve(profileName)
+ if err != nil {
+ return environment.Resolved{}, err
+ }
+ return resolved, nil
+}
+
+func defaultLocalEnvironment() environment.Resolved {
+ return environment.Resolved{
+ Profile: string(environment.BackendLocal),
+ Backend: environment.BackendLocal,
+ SyncMode: environment.SyncModeNone,
+ Persistence: environment.PersistenceTransient,
+ DestroyOnStop: false,
+ }
+}
+
+// Validate ensures the environment profile is internally consistent.
+func (p EnvironmentProfile) Validate(path string) error {
+ backend := environment.Backend(strings.TrimSpace(p.Backend))
+ if !backend.Valid() {
+ return fmt.Errorf(
+ "%s.backend must be one of %q, %q, %q: %q",
+ path,
+ environment.BackendLocal,
+ environment.BackendDaytona,
+ environment.BackendE2B,
+ p.Backend,
+ )
+ }
+
+ if syncMode := strings.TrimSpace(p.SyncMode); syncMode != "" {
+ mode := environment.SyncMode(syncMode)
+ if !mode.Valid() {
+ return fmt.Errorf(
+ "%s.sync_mode must be one of %q, %q, %q: %q",
+ path,
+ environment.SyncModeNone,
+ environment.SyncModeSessionBidirectional,
+ environment.SyncModeTurnBidirectional,
+ p.SyncMode,
+ )
+ }
+ }
+
+ if persistenceMode := strings.TrimSpace(p.Persistence); persistenceMode != "" {
+ mode := environment.PersistenceMode(persistenceMode)
+ if !mode.Valid() {
+ return fmt.Errorf(
+ "%s.persistence must be one of %q, %q, %q: %q",
+ path,
+ environment.PersistenceTransient,
+ environment.PersistenceReuse,
+ environment.PersistenceArchive,
+ p.Persistence,
+ )
+ }
+ }
+
+ return nil
+}
+
+// Resolve converts one validated config profile into runtime environment policy.
+func (p EnvironmentProfile) Resolve(profileName string) (environment.Resolved, error) {
+ if err := p.Validate("environment profile " + profileName); err != nil {
+ return environment.Resolved{}, err
+ }
+
+ backend := environment.Backend(strings.TrimSpace(p.Backend))
+ syncMode := environment.SyncMode(strings.TrimSpace(p.SyncMode))
+ if syncMode == "" {
+ syncMode = defaultSyncModeForBackend(backend)
+ }
+ persistence := environment.PersistenceMode(strings.TrimSpace(p.Persistence))
+ if persistence == "" {
+ persistence = environment.PersistenceTransient
+ }
+
+ resolved := environment.Resolved{
+ Profile: strings.TrimSpace(profileName),
+ Backend: backend,
+ SyncMode: syncMode,
+ Persistence: persistence,
+ RuntimeRootDir: strings.TrimSpace(p.RuntimeRoot),
+ DestroyOnStop: persistence != environment.PersistenceReuse,
+ Env: mergeStringMaps(nil, p.Env),
+ Network: environment.NetworkPolicy{
+ AllowPublicIngress: p.Network.AllowPublicIngress,
+ AllowOutbound: p.Network.AllowOutbound,
+ AllowList: cloneStrings(p.Network.AllowList),
+ DenyList: cloneStrings(p.Network.DenyList),
+ Required: p.Network.Required,
+ },
+ }
+ if backend == environment.BackendDaytona {
+ daytona := p.Daytona.Resolve()
+ resolved.Daytona = &daytona
+ }
+
+ return resolved, nil
+}
+
+func defaultSyncModeForBackend(backend environment.Backend) environment.SyncMode {
+ if backend == environment.BackendLocal {
+ return environment.SyncModeNone
+ }
+ return environment.SyncModeSessionBidirectional
+}
+
+// Resolve converts Daytona profile inputs into provider startup policy.
+func (p DaytonaProfile) Resolve() environment.DaytonaConfig {
+ resolved := environment.DaytonaConfig{
+ APIURL: strings.TrimSpace(p.APIURL),
+ Target: strings.TrimSpace(p.Target),
+ Image: strings.TrimSpace(p.Image),
+ Snapshot: strings.TrimSpace(p.Snapshot),
+ Class: strings.TrimSpace(p.Class),
+ AutoStop: strings.TrimSpace(p.AutoStop),
+ AutoArchive: strings.TrimSpace(p.AutoArchive),
+ }
+ switch {
+ case resolved.Snapshot != "":
+ resolved.StartupSource = environment.DaytonaStartupSourceSnapshot
+ resolved.StartupRef = resolved.Snapshot
+ case resolved.Image != "":
+ resolved.StartupSource = environment.DaytonaStartupSourceImage
+ resolved.StartupRef = resolved.Image
+ }
+ return resolved
+}
+
// Validate ensures the daemon config contains a socket path.
func (c DaemonConfig) Validate() error {
if strings.TrimSpace(c.Socket) == "" {
@@ -579,7 +800,46 @@ func (c SkillsConfig) Validate() error {
// Validate ensures the extension marketplace configuration is internally consistent.
func (c ExtensionsConfig) Validate() error {
- return c.Marketplace.Validate()
+ if err := c.Marketplace.Validate(); err != nil {
+ return err
+ }
+ return c.Resources.Validate()
+}
+
+// Validate ensures the extension resource policy is internally consistent.
+func (c ExtensionsResourcesConfig) Validate() error {
+ if _, err := surfaces.NormalizeAllowedKinds(c.AllowedKinds); err != nil {
+ return fmt.Errorf("extensions.resources.allowed_kinds: %w", err)
+ }
+ if c.MaxScope != "" {
+ if err := c.MaxScope.Validate("extensions.resources.max_scope"); err != nil {
+ return err
+ }
+ }
+ if err := c.SnapshotRateLimit.Validate("extensions.resources.snapshot_rate_limit"); err != nil {
+ return err
+ }
+ if err := c.OperatorWriteRateLimit.Validate("extensions.resources.operator_write_rate_limit"); err != nil {
+ return err
+ }
+ return nil
+}
+
+// Validate ensures one configured resource rate-limit bucket is internally consistent.
+func (c ExtensionsResourceRateLimitConfig) Validate(path string) error {
+ if c.Requests == 0 && c.Window == 0 && c.Queue == 0 {
+ return nil
+ }
+ if c.Requests <= 0 {
+ return fmt.Errorf("%s.requests must be positive: %d", path, c.Requests)
+ }
+ if c.Window <= 0 {
+ return fmt.Errorf("%s.window must be positive: %s", path, c.Window)
+ }
+ if c.Queue < 0 {
+ return fmt.Errorf("%s.queue must be zero or positive: %d", path, c.Queue)
+ }
+ return nil
}
var networkChannelPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 4b091af30..4eef9c8c1 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -10,7 +10,9 @@ import (
"testing"
"time"
+ "github.com/pedronauck/agh/internal/environment"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
)
func TestLoadValidTOMLConfigWithAllSections(t *testing.T) {
@@ -236,6 +238,180 @@ max_queue_depth = 250
}
}
+func TestLoadEnvironmentProfilesFromTOML(t *testing.T) {
+ workspaceRoot := t.TempDir()
+ homeRoot := filepath.Join(t.TempDir(), "home")
+ t.Setenv("AGH_HOME", homeRoot)
+
+ homePaths, err := ResolveHomePaths()
+ if err != nil {
+ t.Fatalf("ResolveHomePaths() error = %v", err)
+ }
+ if err := EnsureHomeLayout(homePaths); err != nil {
+ t.Fatalf("EnsureHomeLayout() error = %v", err)
+ }
+
+ writeFile(t, homePaths.ConfigFile, `
+[defaults]
+environment = "daytona-dev"
+
+[environments.local]
+backend = "local"
+
+[environments.daytona-dev]
+backend = "daytona"
+sync_mode = "session-bidirectional"
+persistence = "reuse"
+runtime_root = "/home/daytona/workspace"
+
+[environments.daytona-dev.env]
+NODE_ENV = "development"
+AGH_PROFILE = "daytona"
+
+[environments.daytona-dev.network]
+allow_public_ingress = false
+allow_outbound = true
+allow_list = ["api.example.test"]
+deny_list = ["metadata.google.internal"]
+
+[environments.daytona-dev.daytona]
+api_url = "https://app.daytona.io/api"
+target = "team-default"
+image = "ubuntu:24.04"
+snapshot = "snap-agent-base"
+class = "cpu-2"
+auto_stop = "30m"
+auto_archive = "24h"
+`)
+
+ cfg, err := Load(WithWorkspaceRoot(workspaceRoot))
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if got, want := cfg.Defaults.Environment, "daytona-dev"; got != want {
+ t.Fatalf("Defaults.Environment = %q, want %q", got, want)
+ }
+ profile := cfg.Environments["daytona-dev"]
+ if profile.Backend != "daytona" || profile.Daytona.Snapshot != "snap-agent-base" {
+ t.Fatalf("daytona profile = %#v, want parsed profile", profile)
+ }
+ if got, want := profile.Env["NODE_ENV"], "development"; got != want {
+ t.Fatalf("profile Env[NODE_ENV] = %q, want %q", got, want)
+ }
+
+ resolved, err := cfg.ResolveEnvironment(cfg.Defaults.Environment)
+ if err != nil {
+ t.Fatalf("ResolveEnvironment() error = %v", err)
+ }
+ if resolved.Backend != environment.BackendDaytona {
+ t.Fatalf("resolved.Backend = %q, want %q", resolved.Backend, environment.BackendDaytona)
+ }
+ if resolved.SyncMode != environment.SyncModeSessionBidirectional ||
+ resolved.Persistence != environment.PersistenceReuse {
+ t.Fatalf("resolved sync/persistence = %q/%q", resolved.SyncMode, resolved.Persistence)
+ }
+ if resolved.Daytona == nil {
+ t.Fatal("resolved.Daytona = nil, want profile")
+ }
+ if got, want := resolved.Daytona.StartupSource, environment.DaytonaStartupSourceSnapshot; got != want {
+ t.Fatalf("resolved Daytona startup source = %q, want %q", got, want)
+ }
+ if got, want := resolved.Daytona.StartupRef, "snap-agent-base"; got != want {
+ t.Fatalf("resolved Daytona startup ref = %q, want %q", got, want)
+ }
+}
+
+func TestDaytonaSnapshotWinsOverImageInResolvedProfile(t *testing.T) {
+ t.Parallel()
+
+ resolved, err := (EnvironmentProfile{
+ Backend: "daytona",
+ Daytona: DaytonaProfile{
+ Image: "ubuntu:24.04",
+ Snapshot: "snap-prebuilt",
+ },
+ }).Resolve("daytona-dev")
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if resolved.Daytona == nil {
+ t.Fatal("resolved.Daytona = nil, want profile")
+ }
+ if got, want := resolved.Daytona.StartupSource, environment.DaytonaStartupSourceSnapshot; got != want {
+ t.Fatalf("StartupSource = %q, want %q", got, want)
+ }
+ if got, want := resolved.Daytona.StartupRef, "snap-prebuilt"; got != want {
+ t.Fatalf("StartupRef = %q, want %q", got, want)
+ }
+ if got, want := resolved.Daytona.Image, "ubuntu:24.04"; got != want {
+ t.Fatalf("Image = %q, want preserved fallback %q", got, want)
+ }
+}
+
+func TestEnvironmentProfileValidationRejectsInvalidBackend(t *testing.T) {
+ t.Parallel()
+
+ homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("ResolveHomePathsFrom() error = %v", err)
+ }
+ cfg := DefaultWithHome(homePaths)
+ cfg.Environments["bad"] = EnvironmentProfile{Backend: "docker"}
+
+ err = cfg.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want invalid backend")
+ }
+ if !strings.Contains(err.Error(), "environments.bad.backend") {
+ t.Fatalf("Validate() error = %v, want environments.bad.backend", err)
+ }
+}
+
+func TestEnvironmentProfileValidationRejectsInvalidSyncMode(t *testing.T) {
+ t.Parallel()
+
+ homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("ResolveHomePathsFrom() error = %v", err)
+ }
+ cfg := DefaultWithHome(homePaths)
+ cfg.Environments["bad"] = EnvironmentProfile{
+ Backend: "daytona",
+ SyncMode: "continuous",
+ }
+
+ err = cfg.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want invalid sync_mode")
+ }
+ if !strings.Contains(err.Error(), "environments.bad.sync_mode") {
+ t.Fatalf("Validate() error = %v, want environments.bad.sync_mode", err)
+ }
+}
+
+func TestEnvironmentProfileValidationRejectsInvalidPersistence(t *testing.T) {
+ t.Parallel()
+
+ homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("ResolveHomePathsFrom() error = %v", err)
+ }
+ cfg := DefaultWithHome(homePaths)
+ cfg.Environments["bad"] = EnvironmentProfile{
+ Backend: "daytona",
+ Persistence: "forever",
+ }
+
+ err = cfg.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want invalid persistence")
+ }
+ if !strings.Contains(err.Error(), "environments.bad.persistence") {
+ t.Fatalf("Validate() error = %v, want environments.bad.persistence", err)
+ }
+}
+
func TestLoadWorkspaceOverridesGlobalValues(t *testing.T) {
workspaceRoot := t.TempDir()
homeRoot := filepath.Join(t.TempDir(), "home")
@@ -695,6 +871,12 @@ func TestDefaultWithHomeLeavesMarketplaceConfigEmpty(t *testing.T) {
if cfg.Extensions.Marketplace != (ExtensionsMarketplaceConfig{}) {
t.Fatalf("DefaultWithHome() Extensions.Marketplace = %#v, want zero value", cfg.Extensions.Marketplace)
}
+ if len(cfg.Extensions.Resources.AllowedKinds) != 0 ||
+ cfg.Extensions.Resources.MaxScope != "" ||
+ cfg.Extensions.Resources.SnapshotRateLimit != (ExtensionsResourceRateLimitConfig{}) ||
+ cfg.Extensions.Resources.OperatorWriteRateLimit != (ExtensionsResourceRateLimitConfig{}) {
+ t.Fatalf("DefaultWithHome() Extensions.Resources = %#v, want zero value", cfg.Extensions.Resources)
+ }
}
func TestSkillsConfigValidateMarketplaceConfig(t *testing.T) {
@@ -832,6 +1014,156 @@ func TestExtensionsConfigValidateMarketplaceConfig(t *testing.T) {
})
}
+func TestExtensionsConfigValidateResourcesConfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cfg ExtensionsConfig
+ wantErrPath string
+ }{
+ {
+ name: "ShouldAcceptValidResourcePolicy",
+ cfg: ExtensionsConfig{
+ Resources: ExtensionsResourcesConfig{
+ AllowedKinds: []resources.ResourceKind{
+ resources.ResourceKind("tool"),
+ resources.ResourceKind("mcp_server"),
+ },
+ MaxScope: resources.ResourceScopeKindWorkspace,
+ SnapshotRateLimit: ExtensionsResourceRateLimitConfig{
+ Requests: 2,
+ Window: 5 * time.Second,
+ Queue: 1,
+ },
+ OperatorWriteRateLimit: ExtensionsResourceRateLimitConfig{
+ Requests: 10,
+ Window: time.Minute,
+ Queue: 0,
+ },
+ },
+ },
+ },
+ {
+ name: "ShouldRejectDaemonOnlyAllowedKind",
+ cfg: ExtensionsConfig{
+ Resources: ExtensionsResourcesConfig{
+ AllowedKinds: []resources.ResourceKind{resources.ResourceKind("bridge.instance")},
+ },
+ },
+ wantErrPath: "extensions.resources.allowed_kinds",
+ },
+ {
+ name: "ShouldRejectInvalidResourceMaxScope",
+ cfg: ExtensionsConfig{
+ Resources: ExtensionsResourcesConfig{
+ MaxScope: resources.ResourceScopeKind("session"),
+ },
+ },
+ wantErrPath: "extensions.resources.max_scope",
+ },
+ {
+ name: "ShouldRejectInvalidSnapshotRateLimit",
+ cfg: ExtensionsConfig{
+ Resources: ExtensionsResourcesConfig{
+ SnapshotRateLimit: ExtensionsResourceRateLimitConfig{
+ Requests: 0,
+ Window: time.Second,
+ },
+ },
+ },
+ wantErrPath: "extensions.resources.snapshot_rate_limit",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.cfg.Validate()
+ if tt.wantErrPath == "" {
+ if err != nil {
+ t.Fatalf("ExtensionsConfig.Validate() error = %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatal("ExtensionsConfig.Validate() error = nil, want validation failure")
+ }
+ if !strings.Contains(err.Error(), tt.wantErrPath) {
+ t.Fatalf("ExtensionsConfig.Validate() error = %v, want %s context", err, tt.wantErrPath)
+ }
+ })
+ }
+}
+
+func TestLoadRoundTripsExtensionsResourcePolicy(t *testing.T) {
+ workspaceRoot := t.TempDir()
+ homeRoot := filepath.Join(t.TempDir(), "home")
+ t.Setenv("AGH_HOME", homeRoot)
+
+ homePaths, err := ResolveHomePaths()
+ if err != nil {
+ t.Fatalf("ResolveHomePaths() error = %v", err)
+ }
+ if err := EnsureHomeLayout(homePaths); err != nil {
+ t.Fatalf("EnsureHomeLayout() error = %v", err)
+ }
+
+ writeFile(t, homePaths.ConfigFile, `
+[extensions.resources]
+allowed_kinds = ["tool", "mcp_server"]
+max_scope = "global"
+
+[extensions.resources.snapshot_rate_limit]
+requests = 3
+window = "15s"
+queue = 1
+
+[extensions.resources.operator_write_rate_limit]
+requests = 12
+window = "1m"
+queue = 0
+`)
+
+ writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), `
+[extensions.resources]
+allowed_kinds = ["tool"]
+max_scope = "workspace"
+
+[extensions.resources.snapshot_rate_limit]
+requests = 1
+window = "5s"
+queue = 1
+`)
+
+ cfg, err := Load(WithWorkspaceRoot(workspaceRoot))
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got, want := cfg.Extensions.Resources.AllowedKinds, []resources.ResourceKind{
+ resources.ResourceKind("tool"),
+ }; !slices.Equal(
+ got,
+ want,
+ ) {
+ t.Fatalf("Load() AllowedKinds = %#v, want %#v", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.MaxScope, resources.ResourceScopeKindWorkspace; got != want {
+ t.Fatalf("Load() MaxScope = %q, want %q", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.SnapshotRateLimit.Requests, 1; got != want {
+ t.Fatalf("Load() SnapshotRateLimit.Requests = %d, want %d", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.SnapshotRateLimit.Window, 5*time.Second; got != want {
+ t.Fatalf("Load() SnapshotRateLimit.Window = %s, want %s", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.OperatorWriteRateLimit.Requests, 12; got != want {
+ t.Fatalf("Load() OperatorWriteRateLimit.Requests = %d, want %d", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.OperatorWriteRateLimit.Window, time.Minute; got != want {
+ t.Fatalf("Load() OperatorWriteRateLimit.Window = %s, want %s", got, want)
+ }
+}
+
func TestValidateRejectsInvalidPorts(t *testing.T) {
homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
if err != nil {
diff --git a/internal/config/mcp_resource.go b/internal/config/mcp_resource.go
new file mode 100644
index 000000000..cc4e0ad11
--- /dev/null
+++ b/internal/config/mcp_resource.go
@@ -0,0 +1,64 @@
+package config
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ // MCPServerResourceKind is the canonical desired-state resource kind for MCP server records.
+ MCPServerResourceKind resources.ResourceKind = "mcp_server"
+ mcpServerResourceMaxBytes = 256 << 10
+)
+
+// NewMCPServerResourceCodec builds the canonical MCP server resource codec.
+func NewMCPServerResourceCodec() (resources.KindCodec[MCPServer], error) {
+ return resources.NewJSONCodec(MCPServerResourceKind, mcpServerResourceMaxBytes, validateMCPServerSpec)
+}
+
+func validateMCPServerSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec MCPServer,
+) (MCPServer, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return MCPServer{}, fmt.Errorf("config: validate mcp resource scope: %w", err)
+ }
+
+ normalized := cloneMCPServer(spec)
+ normalized.Name = strings.TrimSpace(spec.Name)
+ normalized.Command = strings.TrimSpace(spec.Command)
+ for idx, arg := range normalized.Args {
+ normalized.Args[idx] = strings.TrimSpace(arg)
+ }
+ if len(normalized.Env) > 0 {
+ keys := make([]string, 0, len(normalized.Env))
+ for key := range normalized.Env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ canonicalEnv := make(map[string]string, len(keys))
+ for _, key := range keys {
+ trimmedKey := strings.TrimSpace(key)
+ if trimmedKey == "" {
+ continue
+ }
+ canonicalEnv[trimmedKey] = strings.TrimSpace(normalized.Env[key])
+ }
+ if len(canonicalEnv) == 0 {
+ normalized.Env = nil
+ } else {
+ normalized.Env = canonicalEnv
+ }
+ }
+
+ if err := normalized.Validate("mcp_server"); err != nil {
+ return MCPServer{}, fmt.Errorf("config: validate mcp resource spec: %w", err)
+ }
+ return normalized, nil
+}
diff --git a/internal/config/mcp_resource_test.go b/internal/config/mcp_resource_test.go
new file mode 100644
index 000000000..de2a21373
--- /dev/null
+++ b/internal/config/mcp_resource_test.go
@@ -0,0 +1,159 @@
+package config_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/store/globaldb"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestMCPServerResourceCodecRejectsInvalidSpec(t *testing.T) {
+ t.Parallel()
+
+ codec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewMCPServerResourceCodec() error = %v", err)
+ }
+
+ _, err = codec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ []byte(`{"name":"git"}`),
+ )
+ if err == nil {
+ t.Fatal("codec.DecodeAndValidate() error = nil, want missing command failure")
+ }
+ if !strings.Contains(err.Error(), "config: validate mcp resource spec") {
+ t.Fatalf("codec.DecodeAndValidate() error = %v, want mcp resource spec context", err)
+ }
+ if !strings.Contains(err.Error(), "mcp_server.command is required") {
+ t.Fatalf("codec.DecodeAndValidate() error = %v, want missing command failure", err)
+ }
+ _, err = codec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{},
+ []byte(`{"name":"git","command":"npx"}`),
+ )
+ if err == nil {
+ t.Fatal("codec.DecodeAndValidate() scope error = nil, want scope validation failure")
+ }
+ if !strings.Contains(err.Error(), "config: validate mcp resource scope") {
+ t.Fatalf("codec.DecodeAndValidate() scope error = %v, want mcp resource scope context", err)
+ }
+}
+
+func TestMCPServerResourceStoreRoundTripReturnsTypedRecords(t *testing.T) {
+ t.Parallel()
+
+ db, err := globaldb.OpenGlobalDB(testutil.Context(t), filepath.Join(t.TempDir(), store.GlobalDatabaseName))
+ if err != nil {
+ t.Fatalf("globaldb.OpenGlobalDB() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := db.Close(testutil.Context(t)); err != nil {
+ t.Fatalf("db.Close() error = %v", err)
+ }
+ })
+
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewMCPServerResourceCodec() error = %v", err)
+ }
+ store, err := resources.NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("resources.NewStore() error = %v", err)
+ }
+
+ actor := resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "config-tests",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "config-tests",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+
+ record, err := store.Put(testutil.Context(t), actor, resources.Draft[aghconfig.MCPServer]{
+ ID: "git",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: aghconfig.MCPServer{
+ Name: " git ",
+ Command: " npx ",
+ Args: []string{" --stdio "},
+ Env: map[string]string{
+ " TOKEN ": " secret ",
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("store.Put() error = %v", err)
+ }
+
+ if got, want := record.Spec.Name, "git"; got != want {
+ t.Fatalf("record.Spec.Name = %q, want %q", got, want)
+ }
+ if got, want := record.Spec.Command, "npx"; got != want {
+ t.Fatalf("record.Spec.Command = %q, want %q", got, want)
+ }
+ if got, want := len(record.Spec.Args), 1; got != want {
+ t.Fatalf("len(record.Spec.Args) = %d, want %d", got, want)
+ }
+ if got, want := record.Spec.Args[0], "--stdio"; got != want {
+ t.Fatalf("record.Spec.Args[0] = %q, want %q", got, want)
+ }
+ if got, want := record.Spec.Env["TOKEN"], "secret"; got != want {
+ t.Fatalf("record.Spec.Env[TOKEN] = %q, want %q", got, want)
+ }
+
+ listed, err := store.List(testutil.Context(t), actor, resources.ResourceFilter{})
+ if err != nil {
+ t.Fatalf("store.List() error = %v", err)
+ }
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(store.List()) = %d, want %d", got, want)
+ }
+ if listed[0].Spec.Name != "git" {
+ t.Fatalf("listed[0].Spec = %#v, want typed normalized git server", listed[0].Spec)
+ }
+}
+
+func TestMCPServerResourceCodecCanonicalizesCollidingEnvKeysDeterministically(t *testing.T) {
+ t.Parallel()
+
+ codec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewMCPServerResourceCodec() error = %v", err)
+ }
+
+ record, err := codec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ []byte(`{
+ "name":"git",
+ "command":"npx",
+ "env":{
+ " TOKEN ":" first ",
+ "TOKEN":" second "
+ }
+ }`),
+ )
+ if err != nil {
+ t.Fatalf("codec.DecodeAndValidate() error = %v", err)
+ }
+ if got, want := len(record.Env), 1; got != want {
+ t.Fatalf("len(record.Env) = %d, want %d", got, want)
+ }
+ if got, want := record.Env["TOKEN"], "second"; got != want {
+ t.Fatalf("record.Env[TOKEN] = %q, want %q", got, want)
+ }
+}
diff --git a/internal/config/merge.go b/internal/config/merge.go
index 9f6bd725a..8dfd3bfd8 100644
--- a/internal/config/merge.go
+++ b/internal/config/merge.go
@@ -10,25 +10,27 @@ import (
"github.com/BurntSushi/toml"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
)
type configOverlay struct {
- Daemon daemonOverlay `toml:"daemon"`
- HTTP httpOverlay `toml:"http"`
- Defaults defaultsOverlay `toml:"defaults"`
- Limits limitsOverlay `toml:"limits"`
- Session sessionOverlay `toml:"session"`
- Permissions permissionsOverlay `toml:"permissions"`
- MCPServers []mcpServerOverlay `toml:"mcp_servers"`
- Providers map[string]providerOverlay `toml:"providers"`
- Observability observabilityOverlay `toml:"observability"`
- Log logOverlay `toml:"log"`
- Memory memoryOverlay `toml:"memory"`
- Skills skillsOverlay `toml:"skills"`
- Extensions extensionsOverlay `toml:"extensions"`
- Automation automationOverlay `toml:"automation"`
- Hooks hooksOverlay `toml:"hooks"`
- Network networkOverlay `toml:"network"`
+ Daemon daemonOverlay `toml:"daemon"`
+ HTTP httpOverlay `toml:"http"`
+ Defaults defaultsOverlay `toml:"defaults"`
+ Limits limitsOverlay `toml:"limits"`
+ Session sessionOverlay `toml:"session"`
+ Permissions permissionsOverlay `toml:"permissions"`
+ MCPServers []mcpServerOverlay `toml:"mcp_servers"`
+ Providers map[string]providerOverlay `toml:"providers"`
+ Environments map[string]environmentOverlay `toml:"environments"`
+ Observability observabilityOverlay `toml:"observability"`
+ Log logOverlay `toml:"log"`
+ Memory memoryOverlay `toml:"memory"`
+ Skills skillsOverlay `toml:"skills"`
+ Extensions extensionsOverlay `toml:"extensions"`
+ Automation automationOverlay `toml:"automation"`
+ Hooks hooksOverlay `toml:"hooks"`
+ Network networkOverlay `toml:"network"`
}
type daemonOverlay struct {
@@ -41,8 +43,9 @@ type httpOverlay struct {
}
type defaultsOverlay struct {
- Agent *string `toml:"agent"`
- Provider *string `toml:"provider"`
+ Agent *string `toml:"agent"`
+ Provider *string `toml:"provider"`
+ Environment *string `toml:"environment"`
}
type limitsOverlay struct {
@@ -69,6 +72,34 @@ type providerOverlay struct {
MCPServers []mcpServerOverlay `toml:"mcp_servers"`
}
+type environmentOverlay struct {
+ Backend *string `toml:"backend"`
+ SyncMode *string `toml:"sync_mode"`
+ Persistence *string `toml:"persistence"`
+ RuntimeRoot *string `toml:"runtime_root"`
+ Env *map[string]string `toml:"env"`
+ Network networkProfileOverlay `toml:"network"`
+ Daytona daytonaProfileOverlay `toml:"daytona"`
+}
+
+type networkProfileOverlay struct {
+ AllowPublicIngress *bool `toml:"allow_public_ingress"`
+ AllowOutbound *bool `toml:"allow_outbound"`
+ AllowList *[]string `toml:"allow_list"`
+ DenyList *[]string `toml:"deny_list"`
+ Required *bool `toml:"required"`
+}
+
+type daytonaProfileOverlay struct {
+ APIURL *string `toml:"api_url"`
+ Target *string `toml:"target"`
+ Image *string `toml:"image"`
+ Snapshot *string `toml:"snapshot"`
+ Class *string `toml:"class"`
+ AutoStop *string `toml:"auto_stop"`
+ AutoArchive *string `toml:"auto_archive"`
+}
+
type observabilityOverlay struct {
Enabled *bool `toml:"enabled"`
RetentionDays *int `toml:"retention_days"`
@@ -111,6 +142,20 @@ type skillsOverlay struct {
type extensionsOverlay struct {
Marketplace extensionsMarketplaceOverlay `toml:"marketplace"`
+ Resources extensionsResourcesOverlay `toml:"resources"`
+}
+
+type extensionsResourcesOverlay struct {
+ AllowedKinds *[]resources.ResourceKind `toml:"allowed_kinds"`
+ MaxScope *resources.ResourceScopeKind `toml:"max_scope"`
+ SnapshotRateLimit extensionsRateLimitOverlay `toml:"snapshot_rate_limit"`
+ OperatorWriteRateLimit extensionsRateLimitOverlay `toml:"operator_write_rate_limit"`
+}
+
+type extensionsRateLimitOverlay struct {
+ Requests *int `toml:"requests"`
+ Window *time.Duration `toml:"window"`
+ Queue *int `toml:"queue"`
}
type networkOverlay struct {
@@ -181,7 +226,7 @@ func loadConfigOverlayFile(path string) (configOverlay, error) {
return overlay, nil
}
-func (o configOverlay) Apply(dst *Config) error {
+func (o *configOverlay) Apply(dst *Config) error {
o.Daemon.Apply(&dst.Daemon)
o.HTTP.Apply(&dst.HTTP)
o.Defaults.Apply(&dst.Defaults)
@@ -192,6 +237,7 @@ func (o configOverlay) Apply(dst *Config) error {
dst.MCPServers = applyMCPServerOverlays(dst.MCPServers, o.MCPServers)
}
applyProviderOverlays(dst, o.Providers)
+ applyEnvironmentOverlays(dst, o.Environments)
o.Observability.Apply(&dst.Observability)
o.Log.Apply(&dst.Log)
o.Memory.Apply(&dst.Memory)
@@ -226,6 +272,9 @@ func (o defaultsOverlay) Apply(dst *DefaultsConfig) {
if o.Provider != nil {
dst.Provider = *o.Provider
}
+ if o.Environment != nil {
+ dst.Environment = *o.Environment
+ }
}
func (o limitsOverlay) Apply(dst *LimitsConfig) {
@@ -268,6 +317,68 @@ func (o providerOverlay) Apply(dst *ProviderConfig) {
}
}
+func (o environmentOverlay) Apply(dst *EnvironmentProfile) {
+ if o.Backend != nil {
+ dst.Backend = *o.Backend
+ }
+ if o.SyncMode != nil {
+ dst.SyncMode = *o.SyncMode
+ }
+ if o.Persistence != nil {
+ dst.Persistence = *o.Persistence
+ }
+ if o.RuntimeRoot != nil {
+ dst.RuntimeRoot = *o.RuntimeRoot
+ }
+ if o.Env != nil {
+ dst.Env = mergeStringMaps(dst.Env, *o.Env)
+ }
+ o.Network.Apply(&dst.Network)
+ o.Daytona.Apply(&dst.Daytona)
+}
+
+func (o networkProfileOverlay) Apply(dst *NetworkProfile) {
+ if o.AllowPublicIngress != nil {
+ dst.AllowPublicIngress = *o.AllowPublicIngress
+ }
+ if o.AllowOutbound != nil {
+ dst.AllowOutbound = *o.AllowOutbound
+ }
+ if o.AllowList != nil {
+ dst.AllowList = append([]string(nil), (*o.AllowList)...)
+ }
+ if o.DenyList != nil {
+ dst.DenyList = append([]string(nil), (*o.DenyList)...)
+ }
+ if o.Required != nil {
+ dst.Required = *o.Required
+ }
+}
+
+func (o daytonaProfileOverlay) Apply(dst *DaytonaProfile) {
+ if o.APIURL != nil {
+ dst.APIURL = *o.APIURL
+ }
+ if o.Target != nil {
+ dst.Target = *o.Target
+ }
+ if o.Image != nil {
+ dst.Image = *o.Image
+ }
+ if o.Snapshot != nil {
+ dst.Snapshot = *o.Snapshot
+ }
+ if o.Class != nil {
+ dst.Class = *o.Class
+ }
+ if o.AutoStop != nil {
+ dst.AutoStop = *o.AutoStop
+ }
+ if o.AutoArchive != nil {
+ dst.AutoArchive = *o.AutoArchive
+ }
+}
+
func (o observabilityOverlay) Apply(dst *ObservabilityConfig) {
if o.Enabled != nil {
dst.Enabled = *o.Enabled
@@ -348,6 +459,30 @@ func (o skillsOverlay) Apply(dst *SkillsConfig) {
func (o extensionsOverlay) Apply(dst *ExtensionsConfig) {
o.Marketplace.Apply(&dst.Marketplace)
+ o.Resources.Apply(&dst.Resources)
+}
+
+func (o extensionsResourcesOverlay) Apply(dst *ExtensionsResourcesConfig) {
+ if o.AllowedKinds != nil {
+ dst.AllowedKinds = append([]resources.ResourceKind(nil), (*o.AllowedKinds)...)
+ }
+ if o.MaxScope != nil {
+ dst.MaxScope = *o.MaxScope
+ }
+ o.SnapshotRateLimit.Apply(&dst.SnapshotRateLimit)
+ o.OperatorWriteRateLimit.Apply(&dst.OperatorWriteRateLimit)
+}
+
+func (o extensionsRateLimitOverlay) Apply(dst *ExtensionsResourceRateLimitConfig) {
+ if o.Requests != nil {
+ dst.Requests = *o.Requests
+ }
+ if o.Window != nil {
+ dst.Window = *o.Window
+ }
+ if o.Queue != nil {
+ dst.Queue = *o.Queue
+ }
}
func (o networkOverlay) Apply(dst *NetworkConfig) {
@@ -457,6 +592,21 @@ func applyProviderOverlays(dst *Config, overlays map[string]providerOverlay) {
}
}
+func applyEnvironmentOverlays(dst *Config, overlays map[string]environmentOverlay) {
+ if len(overlays) == 0 {
+ return
+ }
+ if dst.Environments == nil {
+ dst.Environments = make(map[string]EnvironmentProfile, len(overlays))
+ }
+
+ for name, overlay := range overlays {
+ profile := dst.Environments[name]
+ overlay.Apply(&profile)
+ dst.Environments[name] = profile
+ }
+}
+
func applyMCPServerOverlays(base []MCPServer, overlays []mcpServerOverlay) []MCPServer {
merged := cloneMCPServers(base)
index := make(map[string]int, len(merged))
diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go
index 6ef479f46..18b6f2329 100644
--- a/internal/config/merge_test.go
+++ b/internal/config/merge_test.go
@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/pedronauck/agh/internal/resources"
)
func TestApplyConfigOverlayFileAppliesSkillsOverlay(t *testing.T) {
@@ -154,6 +156,137 @@ max_queue_depth = 12
})
}
+func TestApplyConfigOverlayFileMergesEnvironmentProfiles(t *testing.T) {
+ t.Parallel()
+
+ homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("ResolveHomePathsFrom() error = %v", err)
+ }
+
+ cfg := DefaultWithHome(homePaths)
+ cfg.Environments["daytona-dev"] = EnvironmentProfile{
+ Backend: "daytona",
+ SyncMode: "session-bidirectional",
+ Persistence: "reuse",
+ RuntimeRoot: "/workspace",
+ Env: map[string]string{
+ "GLOBAL_ONLY": "true",
+ "SHARED": "global",
+ },
+ Network: NetworkProfile{
+ AllowOutbound: true,
+ AllowList: []string{"api.example.test"},
+ },
+ Daytona: DaytonaProfile{
+ APIURL: "https://app.daytona.io/api",
+ Target: "team-default",
+ Image: "ubuntu:24.04",
+ Class: "cpu-2",
+ },
+ }
+
+ overlayPath := filepath.Join(t.TempDir(), "overlay.toml")
+ writeFile(t, overlayPath, `
+[defaults]
+environment = "daytona-dev"
+
+[environments.daytona-dev]
+sync_mode = "none"
+
+[environments.daytona-dev.env]
+SHARED = "workspace"
+WORKSPACE_ONLY = "true"
+
+[environments.daytona-dev.daytona]
+snapshot = "snap-workspace"
+`)
+
+ if err := ApplyConfigOverlayFile(overlayPath, &cfg); err != nil {
+ t.Fatalf("ApplyConfigOverlayFile() error = %v", err)
+ }
+
+ profile := cfg.Environments["daytona-dev"]
+ if got, want := cfg.Defaults.Environment, "daytona-dev"; got != want {
+ t.Fatalf("Defaults.Environment = %q, want %q", got, want)
+ }
+ if profile.Backend != "daytona" || profile.Persistence != "reuse" || profile.RuntimeRoot != "/workspace" {
+ t.Fatalf("environment profile base fields not preserved: %#v", profile)
+ }
+ if profile.SyncMode != "none" {
+ t.Fatalf("environment SyncMode = %q, want none", profile.SyncMode)
+ }
+ if profile.Daytona.APIURL != "https://app.daytona.io/api" ||
+ profile.Daytona.Image != "ubuntu:24.04" ||
+ profile.Daytona.Snapshot != "snap-workspace" {
+ t.Fatalf("Daytona overlay did not preserve provider fields: %#v", profile.Daytona)
+ }
+ if got, want := profile.Env["GLOBAL_ONLY"], "true"; got != want {
+ t.Fatalf("Env[GLOBAL_ONLY] = %q, want %q", got, want)
+ }
+ if got, want := profile.Env["SHARED"], "workspace"; got != want {
+ t.Fatalf("Env[SHARED] = %q, want %q", got, want)
+ }
+ if got, want := profile.Env["WORKSPACE_ONLY"], "true"; got != want {
+ t.Fatalf("Env[WORKSPACE_ONLY] = %q, want %q", got, want)
+ }
+}
+
+func TestApplyConfigOverlayFileAppliesExtensionsResourceOverlay(t *testing.T) {
+ t.Parallel()
+
+ homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("ResolveHomePathsFrom() error = %v", err)
+ }
+
+ cfg := DefaultWithHome(homePaths)
+ cfg.Extensions.Resources.OperatorWriteRateLimit = ExtensionsResourceRateLimitConfig{
+ Requests: 12,
+ Window: time.Minute,
+ Queue: 0,
+ }
+
+ overlayPath := filepath.Join(t.TempDir(), "overlay.toml")
+ writeFile(t, overlayPath, `
+[extensions.resources]
+allowed_kinds = ["tool"]
+max_scope = "workspace"
+
+[extensions.resources.snapshot_rate_limit]
+requests = 2
+window = "8s"
+queue = 1
+`)
+
+ if err := ApplyConfigOverlayFile(overlayPath, &cfg); err != nil {
+ t.Fatalf("ApplyConfigOverlayFile() error = %v", err)
+ }
+ if got, want := cfg.Extensions.Resources.AllowedKinds, []resources.ResourceKind{
+ resources.ResourceKind("tool"),
+ }; !slices.Equal(
+ got,
+ want,
+ ) {
+ t.Fatalf("ApplyConfigOverlayFile() AllowedKinds = %#v, want %#v", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.MaxScope, resources.ResourceScopeKindWorkspace; got != want {
+ t.Fatalf("ApplyConfigOverlayFile() MaxScope = %q, want %q", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.SnapshotRateLimit.Requests, 2; got != want {
+ t.Fatalf("ApplyConfigOverlayFile() SnapshotRateLimit.Requests = %d, want %d", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.SnapshotRateLimit.Window, 8*time.Second; got != want {
+ t.Fatalf("ApplyConfigOverlayFile() SnapshotRateLimit.Window = %s, want %s", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.OperatorWriteRateLimit.Requests, 12; got != want {
+ t.Fatalf("ApplyConfigOverlayFile() OperatorWriteRateLimit.Requests = %d, want %d", got, want)
+ }
+ if got, want := cfg.Extensions.Resources.OperatorWriteRateLimit.Window, time.Minute; got != want {
+ t.Fatalf("ApplyConfigOverlayFile() OperatorWriteRateLimit.Window = %s, want %s", got, want)
+ }
+}
+
func TestValidateWrapsNetworkErrorsWithConfigContext(t *testing.T) {
t.Parallel()
diff --git a/internal/daemon/agent_skill_resources.go b/internal/daemon/agent_skill_resources.go
new file mode 100644
index 000000000..2fb4d0fb2
--- /dev/null
+++ b/internal/daemon/agent_skill_resources.go
@@ -0,0 +1,977 @@
+package daemon
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "slices"
+ "strings"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ skillspkg "github.com/pedronauck/agh/internal/skills"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+const (
+ agentManagedIDPrefix = "daemon.sync.agent."
+ skillManagedIDPrefix = "daemon.sync.skill."
+)
+
+type agentSkillPublisher interface {
+ Sync(context.Context) error
+}
+
+type agentSkillPublisherFunc func(context.Context) error
+
+func (f agentSkillPublisherFunc) Sync(ctx context.Context) error {
+ if f == nil {
+ return nil
+ }
+ return f(ctx)
+}
+
+type agentSkillDeclarationProvider func(context.Context) (agentSkillDesiredResources, error)
+
+type agentPublicationInput struct {
+ sourceKey string
+ scope resources.ResourceScope
+ spec aghconfig.AgentDef
+}
+
+type skillPublicationInput struct {
+ sourceKey string
+ scope resources.ResourceScope
+ spec skillspkg.SkillResourceSpec
+}
+
+type agentSkillDesiredResources struct {
+ agents []agentPublicationInput
+ skills []skillPublicationInput
+ mcpServers []mcpServerPublicationInput
+}
+
+type agentSkillSourceSyncer struct {
+ agentStore resources.Store[aghconfig.AgentDef]
+ agentCodec resources.KindCodec[aghconfig.AgentDef]
+ skillStore resources.Store[skillspkg.SkillResourceSpec]
+ skillCodec resources.KindCodec[skillspkg.SkillResourceSpec]
+ mcpStore resources.Store[aghconfig.MCPServer]
+ mcpCodec resources.KindCodec[aghconfig.MCPServer]
+ actor resources.MutationActor
+ logger *slog.Logger
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ providers []agentSkillDeclarationProvider
+}
+
+type skillResourceProjectionPlan struct {
+ revision int64
+ records []resources.Record[skillspkg.SkillResourceSpec]
+}
+
+func (p *skillResourceProjectionPlan) Kind() resources.ResourceKind {
+ if p == nil {
+ return ""
+ }
+ return skillspkg.SkillResourceKind
+}
+
+func (p *skillResourceProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *skillResourceProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return len(p.records)
+}
+
+type skillResourceProjector struct {
+ registry *skillspkg.Registry
+}
+
+func newAgentProjector(catalog *resourceCatalog[aghconfig.AgentDef]) resources.TypedProjector[aghconfig.AgentDef] {
+ if catalog == nil {
+ return nil
+ }
+ return &resourceCatalogProjector[aghconfig.AgentDef]{
+ kind: aghconfig.AgentResourceKind,
+ catalog: catalog,
+ cloneSpec: cloneAgentDef,
+ }
+}
+
+func newSkillProjector(registry *skillspkg.Registry) resources.TypedProjector[skillspkg.SkillResourceSpec] {
+ if registry == nil {
+ return nil
+ }
+ return &skillResourceProjector{registry: registry}
+}
+
+func (p *skillResourceProjector) Kind() resources.ResourceKind {
+ return skillspkg.SkillResourceKind
+}
+
+func (p *skillResourceProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *skillResourceProjector) Build(
+ _ context.Context,
+ records []resources.Record[skillspkg.SkillResourceSpec],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.registry == nil {
+ return nil, errors.New("daemon: skill resource projector is required")
+ }
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ }
+ return &skillResourceProjectionPlan{
+ revision: revision,
+ records: cloneResourceRecords(records, cloneSkillResourceSpec),
+ }, nil
+}
+
+func (p *skillResourceProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.registry == nil {
+ return errors.New("daemon: skill resource projector is required")
+ }
+ if ctx == nil {
+ return errors.New("daemon: skill resource projector apply context is required")
+ }
+ typed, ok := plan.(*skillResourceProjectionPlan)
+ if !ok {
+ return fmt.Errorf("daemon: skill resource projector plan has type %T", plan)
+ }
+ return p.registry.ApplyResourceRecords(typed.revision, typed.records)
+}
+
+type resourceAgentCatalog struct {
+ catalog *resourceCatalog[aghconfig.AgentDef]
+}
+
+func agentCatalogDependency(catalog *resourceCatalog[aghconfig.AgentDef]) *resourceAgentCatalog {
+ if catalog == nil {
+ return nil
+ }
+ return &resourceAgentCatalog{catalog: catalog}
+}
+
+func (c *resourceAgentCatalog) ResolveAgent(
+ name string,
+ resolved *workspacepkg.ResolvedWorkspace,
+) (aghconfig.AgentDef, error) {
+ target := strings.TrimSpace(name)
+ if target == "" {
+ return aghconfig.AgentDef{}, errors.New("session: agent name is required")
+ }
+ if c == nil || c.catalog == nil {
+ return resolveAgentFromWorkspaceSnapshot(target, resolved)
+ }
+ for _, agent := range c.agentsForWorkspace(resolved) {
+ if strings.TrimSpace(agent.Name) == target {
+ return cloneAgentDef(agent), nil
+ }
+ }
+ return aghconfig.AgentDef{}, fmt.Errorf("%w: %s", workspacepkg.ErrAgentNotAvailable, target)
+}
+
+func resolveAgentFromWorkspaceSnapshot(
+ target string,
+ resolved *workspacepkg.ResolvedWorkspace,
+) (aghconfig.AgentDef, error) {
+ if resolved == nil {
+ return aghconfig.AgentDef{}, errors.New("session: resolved workspace is required")
+ }
+ for _, agent := range resolved.Agents {
+ if strings.TrimSpace(agent.Name) == target {
+ return cloneAgentDef(agent), nil
+ }
+ }
+ return aghconfig.AgentDef{}, fmt.Errorf("%w: %s", workspacepkg.ErrAgentNotAvailable, target)
+}
+
+func (c *resourceAgentCatalog) ListAgents(ctx context.Context) ([]aghconfig.AgentDef, error) {
+ if ctx == nil {
+ return nil, errors.New("daemon: list agent catalog context is required")
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if c == nil || c.catalog == nil {
+ return nil, nil
+ }
+ return c.agentsForWorkspace(nil), nil
+}
+
+func (c *resourceAgentCatalog) GetAgent(ctx context.Context, name string) (aghconfig.AgentDef, error) {
+ if ctx == nil {
+ return aghconfig.AgentDef{}, errors.New("daemon: get agent catalog context is required")
+ }
+ if err := ctx.Err(); err != nil {
+ return aghconfig.AgentDef{}, err
+ }
+ target := strings.TrimSpace(name)
+ if target == "" {
+ return aghconfig.AgentDef{}, errors.New("agent name is required")
+ }
+ for _, agent := range c.agentsForWorkspace(nil) {
+ if strings.TrimSpace(agent.Name) == target {
+ return cloneAgentDef(agent), nil
+ }
+ }
+ return aghconfig.AgentDef{}, fmt.Errorf("%w: %s", os.ErrNotExist, target)
+}
+
+func (c *resourceAgentCatalog) agentsForWorkspace(resolved *workspacepkg.ResolvedWorkspace) []aghconfig.AgentDef {
+ if c == nil || c.catalog == nil {
+ return nil
+ }
+ records := c.catalog.Snapshot()
+ slices.SortFunc(records, func(left, right resources.Record[aghconfig.AgentDef]) int {
+ return strings.Compare(agentRecordSortKey(left), agentRecordSortKey(right))
+ })
+ merged := make(map[string]aghconfig.AgentDef)
+ for _, record := range records {
+ if record.Scope.Kind.Normalize() != resources.ResourceScopeKindGlobal {
+ continue
+ }
+ name := strings.TrimSpace(record.Spec.Name)
+ if name != "" {
+ merged[name] = cloneAgentDef(record.Spec)
+ }
+ }
+ workspaceID := ""
+ if resolved != nil {
+ workspaceID = strings.TrimSpace(resolved.ID)
+ }
+ if workspaceID != "" {
+ for _, record := range records {
+ if record.Scope.Kind.Normalize() != resources.ResourceScopeKindWorkspace ||
+ strings.TrimSpace(record.Scope.ID) != workspaceID {
+ continue
+ }
+ name := strings.TrimSpace(record.Spec.Name)
+ if name != "" {
+ merged[name] = cloneAgentDef(record.Spec)
+ }
+ }
+ }
+ names := make([]string, 0, len(merged))
+ for name := range merged {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+ agents := make([]aghconfig.AgentDef, 0, len(names))
+ for _, name := range names {
+ agents = append(agents, cloneAgentDef(merged[name]))
+ }
+ return agents
+}
+
+func newAgentSkillSourceSyncer(
+ agentStore resources.Store[aghconfig.AgentDef],
+ agentCodec resources.KindCodec[aghconfig.AgentDef],
+ skillStore resources.Store[skillspkg.SkillResourceSpec],
+ skillCodec resources.KindCodec[skillspkg.SkillResourceSpec],
+ mcpStore resources.Store[aghconfig.MCPServer],
+ mcpCodec resources.KindCodec[aghconfig.MCPServer],
+ actor resources.MutationActor,
+ logger *slog.Logger,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+ providers ...agentSkillDeclarationProvider,
+) agentSkillPublisher {
+ if agentStore == nil || agentCodec == nil || skillStore == nil || skillCodec == nil ||
+ mcpStore == nil || mcpCodec == nil {
+ return nil
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &agentSkillSourceSyncer{
+ agentStore: agentStore,
+ agentCodec: agentCodec,
+ skillStore: skillStore,
+ skillCodec: skillCodec,
+ mcpStore: mcpStore,
+ mcpCodec: mcpCodec,
+ actor: actor,
+ logger: logger,
+ trigger: trigger,
+ providers: append([]agentSkillDeclarationProvider(nil), providers...),
+ }
+}
+
+func agentSkillSyncActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "agent-skill-sync",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "agent-skill-sync",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *agentSkillSourceSyncer) Sync(ctx context.Context) error {
+ if s == nil {
+ return nil
+ }
+ if ctx == nil {
+ return errors.New("daemon: agent/skill sync context is required")
+ }
+
+ desired, err := s.desiredResources(ctx)
+ if err != nil {
+ return err
+ }
+ agentChanged, err := s.syncAgents(ctx, desired.agents)
+ if err != nil {
+ return err
+ }
+ skillChanged, err := s.syncSkills(ctx, desired.skills)
+ if err != nil {
+ return err
+ }
+ mcpChanged, err := s.syncMCPServers(ctx, desired.mcpServers)
+ if err != nil {
+ return err
+ }
+
+ if agentChanged && s.trigger != nil {
+ if err := s.trigger(ctx, aghconfig.AgentResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ if skillChanged && s.trigger != nil {
+ if err := s.trigger(ctx, skillspkg.SkillResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ if mcpChanged && s.trigger != nil {
+ if err := s.trigger(ctx, aghconfig.MCPServerResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type desiredAgentResource struct {
+ id string
+ scope resources.ResourceScope
+ spec aghconfig.AgentDef
+ encoded []byte
+}
+
+type desiredSkillResource struct {
+ id string
+ scope resources.ResourceScope
+ spec skillspkg.SkillResourceSpec
+ encoded []byte
+}
+
+func (s *agentSkillSourceSyncer) desiredResources(ctx context.Context) (struct {
+ agents map[string]desiredAgentResource
+ skills map[string]desiredSkillResource
+ mcpServers map[string]desiredMCPServerResource
+}, error) {
+ desired := struct {
+ agents map[string]desiredAgentResource
+ skills map[string]desiredSkillResource
+ mcpServers map[string]desiredMCPServerResource
+ }{
+ agents: make(map[string]desiredAgentResource),
+ skills: make(map[string]desiredSkillResource),
+ mcpServers: make(map[string]desiredMCPServerResource),
+ }
+
+ for _, provider := range s.providers {
+ if provider == nil {
+ continue
+ }
+ items, err := provider(ctx)
+ if err != nil {
+ return desired, err
+ }
+ for _, item := range items.agents {
+ spec, encoded, err := validateAndEncodeAgent(ctx, s.agentCodec, item.scope, item.spec)
+ if err != nil {
+ return desired, err
+ }
+ id := managedResourceID(agentManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ desired.agents[id] = desiredAgentResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ for _, item := range items.skills {
+ spec, encoded, err := validateAndEncodeSkill(ctx, s.skillCodec, item.scope, item.spec)
+ if err != nil {
+ return desired, err
+ }
+ id := managedResourceID(skillManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ desired.skills[id] = desiredSkillResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ for _, item := range items.mcpServers {
+ spec, encoded, err := validateAndEncodeMCPServer(ctx, s.mcpCodec, item.scope, item.spec)
+ if err != nil {
+ return desired, err
+ }
+ id := managedResourceID(mcpServerManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ desired.mcpServers[id] = desiredMCPServerResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ }
+
+ return desired, nil
+}
+
+func (s *agentSkillSourceSyncer) syncAgents(
+ ctx context.Context,
+ desired map[string]desiredAgentResource,
+) (bool, error) {
+ source := s.actor.Source
+ current, err := s.agentStore.List(ctx, s.actor, resources.ResourceFilter{Source: &source})
+ if err != nil {
+ return false, fmt.Errorf("daemon: list managed agents: %w", err)
+ }
+ currentByID := make(map[string]resources.Record[aghconfig.AgentDef], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredAgent := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameAgent(existing, desiredAgent.scope, desiredAgent.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.agentStore.Put(ctx, s.actor, resources.Draft[aghconfig.AgentDef]{
+ ID: desiredAgent.id,
+ Scope: desiredAgent.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredAgent.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync agent %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+ for _, stale := range currentByID {
+ if err := s.agentStore.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale agent %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+ return changed, nil
+}
+
+func (s *agentSkillSourceSyncer) syncSkills(
+ ctx context.Context,
+ desired map[string]desiredSkillResource,
+) (bool, error) {
+ source := s.actor.Source
+ current, err := s.skillStore.List(ctx, s.actor, resources.ResourceFilter{Source: &source})
+ if err != nil {
+ return false, fmt.Errorf("daemon: list managed skills: %w", err)
+ }
+ currentByID := make(map[string]resources.Record[skillspkg.SkillResourceSpec], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredSkill := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameSkill(existing, desiredSkill.scope, desiredSkill.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.skillStore.Put(ctx, s.actor, resources.Draft[skillspkg.SkillResourceSpec]{
+ ID: desiredSkill.id,
+ Scope: desiredSkill.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredSkill.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync skill %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+ for _, stale := range currentByID {
+ if err := s.skillStore.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale skill %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+ return changed, nil
+}
+
+func (s *agentSkillSourceSyncer) syncMCPServers(
+ ctx context.Context,
+ desired map[string]desiredMCPServerResource,
+) (bool, error) {
+ source := s.actor.Source
+ current, err := s.mcpStore.List(ctx, s.actor, resources.ResourceFilter{Source: &source})
+ if err != nil {
+ return false, fmt.Errorf("daemon: list agent/skill mcp servers: %w", err)
+ }
+ currentByID := make(map[string]resources.Record[aghconfig.MCPServer], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredServer := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameMCPServer(existing, desiredServer.scope, desiredServer.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.mcpStore.Put(ctx, s.actor, resources.Draft[aghconfig.MCPServer]{
+ ID: desiredServer.id,
+ Scope: desiredServer.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredServer.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync agent/skill mcp server %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+ for _, stale := range currentByID {
+ if err := s.mcpStore.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale agent/skill mcp server %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+ return changed, nil
+}
+
+func (s *agentSkillSourceSyncer) sameAgent(
+ record resources.Record[aghconfig.AgentDef],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+ currentEncoded, err := s.agentCodec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func (s *agentSkillSourceSyncer) sameSkill(
+ record resources.Record[skillspkg.SkillResourceSpec],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+ currentEncoded, err := s.skillCodec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func (s *agentSkillSourceSyncer) sameMCPServer(
+ record resources.Record[aghconfig.MCPServer],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+ currentEncoded, err := s.mcpCodec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func (d *Daemon) newAgentSkillPublisher(
+ state *bootState,
+ registry *extensionpkg.Registry,
+) (agentSkillPublisher, error) {
+ publisher := agentSkillPublisher(agentSkillPublisherFunc(func(context.Context) error { return nil }))
+ if state == nil {
+ return publisher, nil
+ }
+ if state.resourceKernel == nil || state.resourceCodecs == nil {
+ return publisher, nil
+ }
+
+ agentCodec, err := resources.ResolveCodec[aghconfig.AgentDef](state.resourceCodecs, aghconfig.AgentResourceKind)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve agent codec: %w", err)
+ }
+ agentStore, err := resources.NewStore(state.resourceKernel, agentCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create agent store: %w", err)
+ }
+ skillCodec, err := resources.ResolveCodec[skillspkg.SkillResourceSpec](
+ state.resourceCodecs,
+ skillspkg.SkillResourceKind,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve skill codec: %w", err)
+ }
+ skillStore, err := resources.NewStore(state.resourceKernel, skillCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create skill store: %w", err)
+ }
+ mcpCodec, err := resources.ResolveCodec[aghconfig.MCPServer](state.resourceCodecs, aghconfig.MCPServerResourceKind)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve mcp server codec for agent/skill sync: %w", err)
+ }
+ mcpStore, err := resources.NewStore(state.resourceKernel, mcpCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create mcp server store for agent/skill sync: %w", err)
+ }
+
+ return newAgentSkillSourceSyncer(
+ agentStore,
+ agentCodec,
+ skillStore,
+ skillCodec,
+ mcpStore,
+ mcpCodec,
+ agentSkillSyncActor(),
+ state.logger,
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ daemonAgentSkillDeclarationProvider(
+ d.homePaths,
+ state.registry,
+ state.workspaceResolver,
+ state.skillsRegistry,
+ state.logger,
+ ),
+ extensionAgentSkillDeclarationProvider(registry, state.currentExtensionRuntime, state.logger),
+ ), nil
+}
+
+func daemonAgentSkillDeclarationProvider(
+ homePaths aghconfig.HomePaths,
+ registry Registry,
+ workspaceResolver workspacepkg.RuntimeResolver,
+ skillsRegistry *skillspkg.Registry,
+ logger *slog.Logger,
+) agentSkillDeclarationProvider {
+ return func(ctx context.Context) (agentSkillDesiredResources, error) {
+ desired := agentSkillDesiredResources{}
+ globalScope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ globalAgents, err := aghconfig.LoadWorkspaceAgentDefs("", nil, homePaths)
+ if err != nil {
+ return agentSkillDesiredResources{}, fmt.Errorf("daemon: discover global agents: %w", err)
+ }
+ appendAgentResources(&desired, globalScope, "config/global", globalAgents)
+
+ if skillsRegistry != nil {
+ globalSkills, _, err := skillsRegistry.DiscoverGlobal(ctx)
+ if err != nil {
+ return agentSkillDesiredResources{}, fmt.Errorf("daemon: discover global skills: %w", err)
+ }
+ appendSkillResources(&desired, globalScope, "skills/global", globalSkills)
+ }
+
+ workspaces, err := registeredWorkspaces(ctx, registry, workspaceResolver, logger)
+ if err != nil {
+ return agentSkillDesiredResources{}, err
+ }
+ for idx := range workspaces {
+ resolved := &workspaces[idx]
+ scope := resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: strings.TrimSpace(resolved.ID),
+ }
+ appendAgentResources(&desired, scope, "config/workspace/"+scope.ID, resolved.Agents)
+ if skillsRegistry == nil {
+ continue
+ }
+ workspaceSkills, _, err := skillsRegistry.DiscoverWorkspace(ctx, resolved)
+ if err != nil {
+ return agentSkillDesiredResources{}, fmt.Errorf(
+ "daemon: discover workspace %q skills: %w",
+ scope.ID,
+ err,
+ )
+ }
+ appendSkillResources(&desired, scope, "skills/workspace/"+scope.ID, workspaceSkills)
+ }
+
+ return desired, nil
+ }
+}
+
+func extensionAgentSkillDeclarationProvider(
+ registry *extensionpkg.Registry,
+ runtime func() extensionRuntime,
+ logger *slog.Logger,
+) agentSkillDeclarationProvider {
+ return func(_ context.Context) (agentSkillDesiredResources, error) {
+ if registry == nil || runtime == nil {
+ return agentSkillDesiredResources{}, nil
+ }
+ manager := runtime()
+ if manager == nil {
+ return agentSkillDesiredResources{}, nil
+ }
+
+ infos, err := registry.List()
+ if err != nil {
+ return agentSkillDesiredResources{}, fmt.Errorf("daemon: list extensions for agent/skill sync: %w", err)
+ }
+ slices.SortFunc(infos, func(left, right extensionpkg.ExtensionInfo) int {
+ return strings.Compare(left.Name, right.Name)
+ })
+
+ desired := agentSkillDesiredResources{}
+ globalScope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ for _, info := range infos {
+ if !info.Enabled {
+ continue
+ }
+ ext, err := loadExtensionSnapshot(registry, manager, logger, info.Name)
+ if err != nil {
+ return agentSkillDesiredResources{}, fmt.Errorf(
+ "daemon: load extension %q for agent/skill sync: %w",
+ info.Name,
+ err,
+ )
+ }
+ if ext == nil || ext.Manifest == nil || !ext.Status.Registered {
+ continue
+ }
+ appendAgentResources(&desired, globalScope, "extension/"+ext.Info.Name+"/agents", ext.Agents)
+ appendSkillResources(&desired, globalScope, "extension/"+ext.Info.Name+"/skills", ext.Skills)
+ }
+
+ return desired, nil
+ }
+}
+
+func appendAgentResources(
+ desired *agentSkillDesiredResources,
+ scope resources.ResourceScope,
+ sourcePrefix string,
+ agents []aghconfig.AgentDef,
+) {
+ if desired == nil {
+ return
+ }
+ for _, agent := range agents {
+ name := strings.TrimSpace(agent.Name)
+ if name == "" {
+ continue
+ }
+ desired.agents = append(desired.agents, agentPublicationInput{
+ sourceKey: sourcePrefix + "/agent/" + name,
+ scope: scope,
+ spec: cloneAgentDef(agent),
+ })
+ for _, server := range agent.MCPServers {
+ serverName := strings.TrimSpace(server.Name)
+ if serverName == "" {
+ continue
+ }
+ desired.mcpServers = append(desired.mcpServers, mcpServerPublicationInput{
+ sourceKey: sourcePrefix + "/agent/" + name + "/mcp_server/" + serverName,
+ scope: scope,
+ spec: cloneDaemonMCPServer(server),
+ })
+ }
+ }
+}
+
+func appendSkillResources(
+ desired *agentSkillDesiredResources,
+ scope resources.ResourceScope,
+ sourcePrefix string,
+ skills []*skillspkg.Skill,
+) {
+ if desired == nil {
+ return
+ }
+ for _, skill := range skills {
+ if skill == nil {
+ continue
+ }
+ name := strings.TrimSpace(skill.Meta.Name)
+ if name == "" {
+ continue
+ }
+ desired.skills = append(desired.skills, skillPublicationInput{
+ sourceKey: sourcePrefix + "/skill/" + name,
+ scope: scope,
+ spec: skillspkg.SkillToResourceSpec(skill),
+ })
+ for _, server := range skill.MCPServers {
+ serverName := strings.TrimSpace(server.Name)
+ if serverName == "" {
+ continue
+ }
+ desired.mcpServers = append(desired.mcpServers, mcpServerPublicationInput{
+ sourceKey: sourcePrefix + "/skill/" + name + "/mcp_server/" + serverName,
+ scope: scope,
+ spec: mcpServerFromSkillDecl(server),
+ })
+ }
+ }
+}
+
+func mcpServerFromSkillDecl(decl skillspkg.MCPServerDecl) aghconfig.MCPServer {
+ return aghconfig.MCPServer{
+ Name: strings.TrimSpace(decl.Name),
+ Command: strings.TrimSpace(decl.Command),
+ Args: slices.Clone(decl.Args),
+ Env: cloneStringMap(decl.Env),
+ }
+}
+
+func validateAndEncodeAgent(
+ ctx context.Context,
+ codec resources.KindCodec[aghconfig.AgentDef],
+ scope resources.ResourceScope,
+ spec aghconfig.AgentDef,
+) (aghconfig.AgentDef, []byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return aghconfig.AgentDef{}, nil, err
+ }
+ validated, err := codec.DecodeAndValidate(ctx, scope.Normalize(), encoded)
+ if err != nil {
+ return aghconfig.AgentDef{}, nil, err
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return aghconfig.AgentDef{}, nil, err
+ }
+ return validated, canonical, nil
+}
+
+func validateAndEncodeSkill(
+ ctx context.Context,
+ codec resources.KindCodec[skillspkg.SkillResourceSpec],
+ scope resources.ResourceScope,
+ spec skillspkg.SkillResourceSpec,
+) (skillspkg.SkillResourceSpec, []byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return skillspkg.SkillResourceSpec{}, nil, err
+ }
+ validated, err := codec.DecodeAndValidate(ctx, scope.Normalize(), encoded)
+ if err != nil {
+ return skillspkg.SkillResourceSpec{}, nil, err
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return skillspkg.SkillResourceSpec{}, nil, err
+ }
+ return validated, canonical, nil
+}
+
+func cloneAgentDef(agent aghconfig.AgentDef) aghconfig.AgentDef {
+ return aghconfig.AgentDef{
+ Name: strings.TrimSpace(agent.Name),
+ Provider: strings.TrimSpace(agent.Provider),
+ Command: strings.TrimSpace(agent.Command),
+ Model: strings.TrimSpace(agent.Model),
+ Tools: slices.Clone(agent.Tools),
+ Permissions: strings.TrimSpace(agent.Permissions),
+ MCPServers: cloneMCPServers(agent.MCPServers),
+ Hooks: cloneHookDecls(agent.Hooks),
+ Prompt: strings.TrimSpace(agent.Prompt),
+ }
+}
+
+func cloneMCPServers(src []aghconfig.MCPServer) []aghconfig.MCPServer {
+ if len(src) == 0 {
+ return nil
+ }
+ cloned := make([]aghconfig.MCPServer, 0, len(src))
+ for _, server := range src {
+ cloned = append(cloned, cloneDaemonMCPServer(server))
+ }
+ return cloned
+}
+
+func cloneHookDecls(src []hookspkg.HookDecl) []hookspkg.HookDecl {
+ if len(src) == 0 {
+ return nil
+ }
+ cloned := make([]hookspkg.HookDecl, 0, len(src))
+ for _, decl := range src {
+ next := decl
+ next.Args = slices.Clone(decl.Args)
+ next.Env = cloneStringMap(decl.Env)
+ next.Metadata = cloneStringMap(decl.Metadata)
+ if decl.Matcher.ToolReadOnly != nil {
+ value := *decl.Matcher.ToolReadOnly
+ next.Matcher.ToolReadOnly = &value
+ }
+ cloned = append(cloned, next)
+ }
+ return cloned
+}
+
+func cloneSkillResourceSpec(src skillspkg.SkillResourceSpec) skillspkg.SkillResourceSpec {
+ skill, err := skillspkg.SkillFromResourceSpec(src)
+ if err != nil {
+ return src
+ }
+ return skillspkg.SkillToResourceSpec(skill)
+}
+
+func agentRecordSortKey(record resources.Record[aghconfig.AgentDef]) string {
+ return string(record.Scope.Kind.Normalize()) + "\x00" +
+ strings.TrimSpace(record.Scope.ID) + "\x00" +
+ string(record.Source.Kind.Normalize()) + "\x00" +
+ strings.TrimSpace(record.Source.ID) + "\x00" +
+ strings.TrimSpace(record.ID)
+}
diff --git a/internal/daemon/agent_skill_resources_integration_test.go b/internal/daemon/agent_skill_resources_integration_test.go
new file mode 100644
index 000000000..bd1a83b54
--- /dev/null
+++ b/internal/daemon/agent_skill_resources_integration_test.go
@@ -0,0 +1,481 @@
+//go:build integration
+
+package daemon
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ skillspkg "github.com/pedronauck/agh/internal/skills"
+ "github.com/pedronauck/agh/internal/testutil"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+func TestAgentSkillPublicationAndBootRebuild(t *testing.T) {
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+
+ agentCodec, err := aghconfig.NewAgentResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewAgentResourceCodec() error = %v", err)
+ }
+ agentStore, err := resources.NewStore(kernel, agentCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(agent) error = %v", err)
+ }
+ skillCodec, err := skillspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("skillspkg.NewResourceCodec() error = %v", err)
+ }
+ skillStore, err := resources.NewStore(kernel, skillCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(skill) error = %v", err)
+ }
+ mcpCodec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err)
+ }
+ mcpStore, err := resources.NewStore(kernel, mcpCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(mcp) error = %v", err)
+ }
+
+ homePaths := agentSkillIntegrationHome(t)
+ workspaceRoot := agentSkillIntegrationWorkspace(t)
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ workspace := workspacepkg.Workspace{
+ ID: "ws_agent_skill",
+ RootDir: workspaceRoot,
+ Name: "agent-skill",
+ DefaultAgent: "coder",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.InsertWorkspace(testutil.Context(t), workspace); err != nil {
+ t.Fatalf("InsertWorkspace() error = %v", err)
+ }
+ workspaceResolver, err := workspacepkg.NewResolver(
+ db,
+ workspacepkg.WithHomePaths(homePaths),
+ workspacepkg.WithLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("workspace.NewResolver() error = %v", err)
+ }
+
+ extensionRegistry := extensionpkg.NewRegistry(db.DB())
+ extensionSnapshot := agentSkillIntegrationExtension(t, extensionRegistry)
+ runtime := &agentSkillIntegrationRuntime{extension: extensionSnapshot}
+
+ initialAgentCatalog := newResourceCatalog(cloneAgentDef)
+ initialSkillRegistry := skillspkg.NewRegistry(agentSkillIntegrationSkillConfig(homePaths), skillspkg.WithLogger(discardLogger()))
+ initialMCPCatalog := newResourceCatalog(cloneDaemonMCPServer)
+ driver := newAgentSkillIntegrationDriver(
+ t,
+ kernel,
+ agentCodec,
+ skillCodec,
+ mcpCodec,
+ initialAgentCatalog,
+ initialSkillRegistry,
+ initialMCPCatalog,
+ )
+
+ syncer := newAgentSkillSourceSyncer(
+ agentStore,
+ agentCodec,
+ skillStore,
+ skillCodec,
+ mcpStore,
+ mcpCodec,
+ agentSkillSyncActor(),
+ discardLogger(),
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ return driver.Trigger(ctx, kind, reason)
+ },
+ daemonAgentSkillDeclarationProvider(
+ homePaths,
+ db,
+ workspaceResolver,
+ initialSkillRegistry,
+ discardLogger(),
+ ),
+ extensionAgentSkillDeclarationProvider(
+ extensionRegistry,
+ func() extensionRuntime { return runtime },
+ discardLogger(),
+ ),
+ )
+ if err := syncer.Sync(testutil.Context(t)); err != nil {
+ t.Fatalf("syncer.Sync() error = %v", err)
+ }
+
+ source := agentSkillSyncActor().Source
+ agents, err := agentStore.List(testutil.Context(t), agentSkillSyncActor(), resources.ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("agentStore.List() error = %v", err)
+ }
+ if got, want := len(agents), 2; got != want {
+ t.Fatalf("len(agentStore.List()) = %d, want %d (%#v)", got, want, agents)
+ }
+ skills, err := skillStore.List(testutil.Context(t), agentSkillSyncActor(), resources.ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("skillStore.List() error = %v", err)
+ }
+ if got, want := len(skills), 2; got != want {
+ t.Fatalf("len(skillStore.List()) = %d, want %d (%#v)", got, want, skills)
+ }
+ servers, err := mcpStore.List(testutil.Context(t), agentSkillSyncActor(), resources.ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("mcpStore.List() error = %v", err)
+ }
+ if got, want := len(servers), 4; got != want {
+ t.Fatalf("len(mcpStore.List()) = %d, want %d (%#v)", got, want, servers)
+ }
+ if err := syncer.Sync(testutil.Context(t)); err != nil {
+ t.Fatalf("second syncer.Sync() error = %v", err)
+ }
+
+ rebuiltAgentCatalog := newResourceCatalog(cloneAgentDef)
+ rebuiltSkillRegistry := skillspkg.NewRegistry(agentSkillIntegrationSkillConfig(homePaths), skillspkg.WithLogger(discardLogger()))
+ rebuiltMCPCatalog := newResourceCatalog(cloneDaemonMCPServer)
+ bootDriver := newAgentSkillIntegrationDriver(
+ t,
+ kernel,
+ agentCodec,
+ skillCodec,
+ mcpCodec,
+ rebuiltAgentCatalog,
+ rebuiltSkillRegistry,
+ rebuiltMCPCatalog,
+ )
+ if err := bootDriver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("bootDriver.RunBoot() error = %v", err)
+ }
+
+ resolved, err := workspaceResolver.Resolve(testutil.Context(t), workspace.ID)
+ if err != nil {
+ t.Fatalf("workspaceResolver.Resolve() error = %v", err)
+ }
+ agentCatalog := agentCatalogDependency(rebuiltAgentCatalog)
+ coder, err := agentCatalog.ResolveAgent("coder", &resolved)
+ if err != nil {
+ t.Fatalf("ResolveAgent(coder) error = %v", err)
+ }
+ if !slices.Contains(coder.Tools, "lookup") {
+ t.Fatalf("ResolveAgent(coder).Tools = %#v, want lookup tool reference preserved", coder.Tools)
+ }
+ if !agentHasMCP(coder, "workspace-agent-mcp") {
+ t.Fatalf("ResolveAgent(coder).MCPServers = %#v, want workspace-agent-mcp", coder.MCPServers)
+ }
+ extAgent, err := agentCatalog.ResolveAgent("ext-agent", &resolved)
+ if err != nil {
+ t.Fatalf("ResolveAgent(ext-agent) error = %v", err)
+ }
+ if !agentHasMCP(extAgent, "ext-agent-mcp") {
+ t.Fatalf("ResolveAgent(ext-agent).MCPServers = %#v, want ext-agent-mcp", extAgent.MCPServers)
+ }
+
+ projectedSkills, err := rebuiltSkillRegistry.ForWorkspace(testutil.Context(t), &resolved)
+ if err != nil {
+ t.Fatalf("rebuiltSkillRegistry.ForWorkspace() error = %v", err)
+ }
+ review := findIntegrationSkill(projectedSkills, "workspace-review")
+ if review == nil {
+ t.Fatalf("ForWorkspace() = %#v, want workspace-review", projectedSkills)
+ }
+ if !skillHasMCP(review, "workspace-skill-mcp") {
+ t.Fatalf("workspace-review MCPServers = %#v, want workspace-skill-mcp", review.MCPServers)
+ }
+ extSkill := findIntegrationSkill(projectedSkills, "ext-skill")
+ if extSkill == nil {
+ t.Fatalf("ForWorkspace() = %#v, want ext-skill", projectedSkills)
+ }
+ if !skillHasMCP(extSkill, "ext-skill-mcp") {
+ t.Fatalf("ext-skill MCPServers = %#v, want ext-skill-mcp", extSkill.MCPServers)
+ }
+ if !mcpCatalogHas(rebuiltMCPCatalog, "workspace-agent-mcp") ||
+ !mcpCatalogHas(rebuiltMCPCatalog, "workspace-skill-mcp") ||
+ !mcpCatalogHas(rebuiltMCPCatalog, "ext-agent-mcp") ||
+ !mcpCatalogHas(rebuiltMCPCatalog, "ext-skill-mcp") {
+ t.Fatalf("rebuilt MCP catalog = %#v, want all agent/skill MCP attachments", rebuiltMCPCatalog.Snapshot())
+ }
+}
+
+type agentSkillIntegrationRuntime struct {
+ extension *extensionpkg.Extension
+}
+
+func (r *agentSkillIntegrationRuntime) Start(context.Context) error { return nil }
+func (r *agentSkillIntegrationRuntime) Stop(context.Context) error { return nil }
+func (r *agentSkillIntegrationRuntime) Reload(context.Context) error { return nil }
+
+func (r *agentSkillIntegrationRuntime) Get(name string) (*extensionpkg.Extension, error) {
+ if r.extension == nil || r.extension.Info.Name != name {
+ return nil, &extensionpkg.ExtensionNotFoundError{Name: name}
+ }
+ return r.extension, nil
+}
+
+func (r *agentSkillIntegrationRuntime) HookDeclarations(context.Context) ([]hookspkg.HookDecl, error) {
+ return nil, nil
+}
+
+func newAgentSkillIntegrationDriver(
+ t *testing.T,
+ kernel resources.RawStore,
+ agentCodec resources.KindCodec[aghconfig.AgentDef],
+ skillCodec resources.KindCodec[skillspkg.SkillResourceSpec],
+ mcpCodec resources.KindCodec[aghconfig.MCPServer],
+ agentCatalog *resourceCatalog[aghconfig.AgentDef],
+ skillRegistry *skillspkg.Registry,
+ mcpCatalog *resourceCatalog[aghconfig.MCPServer],
+) resources.ReconcileDriver {
+ t.Helper()
+
+ agentRegistration, err := resources.NewTypedProjectorRegistration(agentCodec, newAgentProjector(agentCatalog))
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(agent) error = %v", err)
+ }
+ skillRegistration, err := resources.NewTypedProjectorRegistration(skillCodec, newSkillProjector(skillRegistry))
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(skill) error = %v", err)
+ }
+ mcpRegistration, err := resources.NewTypedProjectorRegistration(mcpCodec, newMCPServerProjector(mcpCatalog))
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(mcp) error = %v", err)
+ }
+ driver, err := resources.NewReconcileDriver(
+ kernel,
+ resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "agent-skill-integration",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "agent-skill-integration",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ []resources.ProjectorRegistration{agentRegistration, skillRegistration, mcpRegistration},
+ resources.WithReconcileLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := driver.Close(context.Background()); err != nil {
+ t.Fatalf("driver.Close() error = %v", err)
+ }
+ })
+ return driver
+}
+
+func agentSkillIntegrationHome(t *testing.T) aghconfig.HomePaths {
+ t.Helper()
+
+ homePaths, err := aghconfig.ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home"))
+ if err != nil {
+ t.Fatalf("aghconfig.ResolveHomePathsFrom() error = %v", err)
+ }
+ if err := aghconfig.EnsureHomeLayout(homePaths); err != nil {
+ t.Fatalf("aghconfig.EnsureHomeLayout() error = %v", err)
+ }
+ return homePaths
+}
+
+func agentSkillIntegrationSkillConfig(homePaths aghconfig.HomePaths) skillspkg.RegistryConfig {
+ return skillspkg.RegistryConfig{
+ UserSkillsDir: homePaths.SkillsDir,
+ UserAgentsDir: homePaths.AgentsDir,
+ }
+}
+
+func agentSkillIntegrationWorkspace(t *testing.T) string {
+ t.Helper()
+
+ root := filepath.Join(t.TempDir(), "workspace")
+ agentDir := filepath.Join(root, aghconfig.DirName, aghconfig.AgentsDirName, "coder")
+ writeAgentSkillIntegrationFile(t, filepath.Join(agentDir, "AGENT.md"), `---
+name: coder
+provider: claude
+tools: ["lookup"]
+---
+
+Use the workspace tool catalog.
+`)
+ writeAgentSkillIntegrationFile(t, filepath.Join(agentDir, aghconfig.MCPJSONName), `{
+ "mcpServers": {
+ "workspace-agent-mcp": {
+ "command": "workspace-agent-command"
+ }
+ }
+}`)
+
+ skillDir := filepath.Join(root, aghconfig.DirName, aghconfig.SkillsDirName, "workspace-review")
+ writeAgentSkillIntegrationFile(t, filepath.Join(skillDir, "SKILL.md"), `---
+name: workspace-review
+description: Workspace review skill
+---
+
+Review workspace changes.
+`)
+ writeAgentSkillIntegrationFile(t, filepath.Join(skillDir, aghconfig.MCPJSONName), `{
+ "mcpServers": {
+ "workspace-skill-mcp": {
+ "command": "workspace-skill-command"
+ }
+ }
+}`)
+
+ canonical, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatalf("filepath.EvalSymlinks(%q) error = %v", root, err)
+ }
+ return canonical
+}
+
+func agentSkillIntegrationExtension(
+ t *testing.T,
+ registry *extensionpkg.Registry,
+) *extensionpkg.Extension {
+ t.Helper()
+
+ dir := t.TempDir()
+ writeAgentSkillIntegrationFile(t, filepath.Join(dir, "extension.toml"), `[extension]
+name = "agent-skill-ext"
+version = "0.1.0"
+min_agh_version = "0.5.0"
+
+[resources]
+skills = ["skills/"]
+agents = ["agents/"]
+`)
+ agentPath := filepath.Join(dir, "agents", "ext-agent.md")
+ writeAgentSkillIntegrationFile(t, agentPath, `---
+name: ext-agent
+provider: claude
+mcp_servers:
+ - name: ext-agent-mcp
+ command: ext-agent-command
+---
+
+Use extension-provided context.
+`)
+ skillPath := filepath.Join(dir, "skills", "ext-skill.md")
+ writeAgentSkillIntegrationFile(t, skillPath, `---
+name: ext-skill
+description: Extension skill
+---
+
+Use extension skill context.
+`)
+ writeAgentSkillIntegrationFile(t, filepath.Join(dir, "skills", aghconfig.MCPJSONName), `{
+ "mcpServers": {
+ "ext-skill-mcp": {
+ "command": "ext-skill-command"
+ }
+ }
+}`)
+
+ manifest, err := extensionpkg.LoadManifest(dir)
+ if err != nil {
+ t.Fatalf("extensionpkg.LoadManifest() error = %v", err)
+ }
+ checksum, err := extensionpkg.ComputeDirectoryChecksum(dir)
+ if err != nil {
+ t.Fatalf("extensionpkg.ComputeDirectoryChecksum() error = %v", err)
+ }
+ if err := registry.Install(manifest, dir, checksum); err != nil {
+ t.Fatalf("registry.Install() error = %v", err)
+ }
+ info, err := registry.Get(manifest.Name)
+ if err != nil {
+ t.Fatalf("registry.Get(%q) error = %v", manifest.Name, err)
+ }
+ agent, err := aghconfig.LoadAgentDefFile(agentPath)
+ if err != nil {
+ t.Fatalf("aghconfig.LoadAgentDefFile(%q) error = %v", agentPath, err)
+ }
+ skill, err := skillspkg.ParseSkillFileWithSource(skillPath, skillspkg.SourceUser)
+ if err != nil {
+ t.Fatalf("skillspkg.ParseSkillFileWithSource(%q) error = %v", skillPath, err)
+ }
+ return &extensionpkg.Extension{
+ Info: *info,
+ Manifest: manifest,
+ RootDir: dir,
+ Agents: []aghconfig.AgentDef{agent},
+ Skills: []*skillspkg.Skill{skill},
+ Status: extensionpkg.ExtensionStatus{
+ Name: info.Name,
+ Version: info.Version,
+ Source: info.Source,
+ Enabled: info.Enabled,
+ Registered: true,
+ },
+ }
+}
+
+func writeAgentSkillIntegrationFile(t *testing.T, path string, content string) {
+ t.Helper()
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("os.MkdirAll(%q) error = %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("os.WriteFile(%q) error = %v", path, err)
+ }
+}
+
+func agentHasMCP(agent aghconfig.AgentDef, name string) bool {
+ for _, server := range agent.MCPServers {
+ if server.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func skillHasMCP(skill *skillspkg.Skill, name string) bool {
+ if skill == nil {
+ return false
+ }
+ for _, server := range skill.MCPServers {
+ if server.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func findIntegrationSkill(skills []*skillspkg.Skill, name string) *skillspkg.Skill {
+ for _, skill := range skills {
+ if skill != nil && skill.Meta.Name == name {
+ return skill
+ }
+ }
+ return nil
+}
+
+func mcpCatalogHas(catalog *resourceCatalog[aghconfig.MCPServer], name string) bool {
+ if catalog == nil {
+ return false
+ }
+ for _, record := range catalog.Snapshot() {
+ if record.Spec.Name == name {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/daemon/agent_skill_resources_test.go b/internal/daemon/agent_skill_resources_test.go
new file mode 100644
index 000000000..139ab4c98
--- /dev/null
+++ b/internal/daemon/agent_skill_resources_test.go
@@ -0,0 +1,383 @@
+package daemon
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ skillspkg "github.com/pedronauck/agh/internal/skills"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+func TestResourceAgentCatalogListsGetsAndResolvesByScope(t *testing.T) {
+ t.Parallel()
+
+ catalog := newResourceCatalog(cloneAgentDef)
+ catalog.Replace(3, []resources.Record[aghconfig.AgentDef]{
+ {
+ ID: "global:alpha",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: aghconfig.AgentDef{Name: "alpha", Prompt: "global alpha"},
+ },
+ {
+ ID: "global:coder",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: aghconfig.AgentDef{Name: "coder", Prompt: "global coder"},
+ },
+ {
+ ID: "workspace:coder",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-1"},
+ Spec: aghconfig.AgentDef{Name: "coder", Prompt: "workspace coder", Tools: []string{"lookup"}},
+ },
+ })
+
+ dependency := agentCatalogDependency(catalog)
+ listed, err := dependency.ListAgents(context.Background())
+ if err != nil {
+ t.Fatalf("ListAgents() error = %v", err)
+ }
+ if got, want := len(listed), 2; got != want {
+ t.Fatalf("len(ListAgents()) = %d, want %d", got, want)
+ }
+ if listed[0].Name != "alpha" || listed[1].Name != "coder" {
+ t.Fatalf("ListAgents() = %#v, want global agents sorted by name", listed)
+ }
+
+ got, err := dependency.GetAgent(context.Background(), "alpha")
+ if err != nil {
+ t.Fatalf("GetAgent(alpha) error = %v", err)
+ }
+ if got.Prompt != "global alpha" {
+ t.Fatalf("GetAgent(alpha).Prompt = %q, want global alpha", got.Prompt)
+ }
+ if _, err := dependency.GetAgent(context.Background(), "missing"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("GetAgent(missing) error = %v, want os.ErrNotExist", err)
+ }
+
+ resolved := &workspacepkg.ResolvedWorkspace{Workspace: workspacepkg.Workspace{ID: "ws-1"}}
+ coder, err := dependency.ResolveAgent("coder", resolved)
+ if err != nil {
+ t.Fatalf("ResolveAgent(coder) error = %v", err)
+ }
+ if coder.Prompt != "workspace coder" || len(coder.Tools) != 1 || coder.Tools[0] != "lookup" {
+ t.Fatalf("ResolveAgent(coder) = %#v, want workspace override", coder)
+ }
+}
+
+func TestResourceAgentCatalogFallsBackToResolvedWorkspaceSnapshot(t *testing.T) {
+ t.Parallel()
+
+ resolved := &workspacepkg.ResolvedWorkspace{
+ Agents: []aghconfig.AgentDef{{
+ Name: "fallback",
+ Prompt: "resolved snapshot",
+ }},
+ }
+ got, err := (&resourceAgentCatalog{}).ResolveAgent("fallback", resolved)
+ if err != nil {
+ t.Fatalf("ResolveAgent(fallback) error = %v", err)
+ }
+ if got.Prompt != "resolved snapshot" {
+ t.Fatalf("ResolveAgent(fallback).Prompt = %q, want resolved snapshot", got.Prompt)
+ }
+ if _, err := (&resourceAgentCatalog{}).ResolveAgent(
+ "missing",
+ resolved,
+ ); !errors.Is(
+ err,
+ workspacepkg.ErrAgentNotAvailable,
+ ) {
+ t.Fatalf("ResolveAgent(missing) error = %v, want ErrAgentNotAvailable", err)
+ }
+}
+
+func TestAgentSkillSmallHelpers(t *testing.T) {
+ t.Parallel()
+
+ var nilPublisher agentSkillPublisherFunc
+ if err := nilPublisher.Sync(context.Background()); err != nil {
+ t.Fatalf("nil publisher Sync() error = %v", err)
+ }
+ called := false
+ publisher := agentSkillPublisherFunc(func(context.Context) error {
+ called = true
+ return nil
+ })
+ if err := publisher.Sync(context.Background()); err != nil {
+ t.Fatalf("publisher Sync() error = %v", err)
+ }
+ if !called {
+ t.Fatal("publisher Sync() did not call function")
+ }
+ if agentCatalogDependency(nil) != nil {
+ t.Fatal("agentCatalogDependency(nil) != nil")
+ }
+ if newAgentProjector(nil) != nil {
+ t.Fatal("newAgentProjector(nil) != nil")
+ }
+ if newSkillProjector(nil) != nil {
+ t.Fatal("newSkillProjector(nil) != nil")
+ }
+
+ var nilPlan *skillResourceProjectionPlan
+ if nilPlan.Kind() != "" || nilPlan.Revision() != 0 || nilPlan.OperationCount() != 0 {
+ t.Fatalf(
+ "nil skillResourceProjectionPlan methods = (%q,%d,%d), want zero values",
+ nilPlan.Kind(),
+ nilPlan.Revision(),
+ nilPlan.OperationCount(),
+ )
+ }
+ plan := &skillResourceProjectionPlan{
+ revision: 4,
+ records: []resources.Record[skillspkg.SkillResourceSpec]{{
+ ID: "skill",
+ }},
+ }
+ if plan.Kind() != skillspkg.SkillResourceKind || plan.Revision() != 4 || plan.OperationCount() != 1 {
+ t.Fatalf(
+ "skillResourceProjectionPlan methods = (%q,%d,%d), want skill,4,1",
+ plan.Kind(),
+ plan.Revision(),
+ plan.OperationCount(),
+ )
+ }
+}
+
+func TestAgentSkillSourceSyncerReplacesCanonicalSnapshot(t *testing.T) {
+ t.Parallel()
+
+ agentStore, agentCodec, skillStore, skillCodec, mcpStore, mcpCodec := agentSkillSyncStores(t)
+ desired := agentSkillDesiredResources{
+ agents: []agentPublicationInput{{
+ sourceKey: "test/agent/coder",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ spec: aghconfig.AgentDef{
+ Name: "coder",
+ Prompt: "Use canonical tools.",
+ Tools: []string{"lookup"},
+ },
+ }},
+ skills: []skillPublicationInput{{
+ sourceKey: "test/skill/review",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ spec: skillspkg.SkillResourceSpec{
+ Name: "review",
+ Description: "Review skill",
+ Source: skillspkg.SkillSourceName(skillspkg.SourceUser),
+ Enabled: true,
+ },
+ }},
+ mcpServers: []mcpServerPublicationInput{{
+ sourceKey: "test/mcp/review",
+ scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ spec: aghconfig.MCPServer{
+ Name: "review-mcp",
+ Command: "review-command",
+ },
+ }},
+ }
+ triggered := make(map[resources.ResourceKind]int)
+ syncer := newAgentSkillSourceSyncer(
+ agentStore,
+ agentCodec,
+ skillStore,
+ skillCodec,
+ mcpStore,
+ mcpCodec,
+ agentSkillSyncActor(),
+ discardLogger(),
+ func(_ context.Context, kind resources.ResourceKind, _ resources.ReconcileReason) error {
+ triggered[kind]++
+ return nil
+ },
+ func(context.Context) (agentSkillDesiredResources, error) {
+ return desired, nil
+ },
+ )
+
+ if err := syncer.Sync(context.Background()); err != nil {
+ t.Fatalf("Sync() error = %v", err)
+ }
+ assertAgentSkillStoreCounts(t, agentStore, skillStore, mcpStore, 1, 1, 1)
+ if triggered[aghconfig.AgentResourceKind] != 1 ||
+ triggered[skillspkg.SkillResourceKind] != 1 ||
+ triggered[aghconfig.MCPServerResourceKind] != 1 {
+ t.Fatalf("triggered = %#v, want one trigger per migrated kind", triggered)
+ }
+
+ if err := syncer.Sync(context.Background()); err != nil {
+ t.Fatalf("second Sync() error = %v", err)
+ }
+ if triggered[aghconfig.AgentResourceKind] != 1 ||
+ triggered[skillspkg.SkillResourceKind] != 1 ||
+ triggered[aghconfig.MCPServerResourceKind] != 1 {
+ t.Fatalf("triggered after no-op = %#v, want no additional triggers", triggered)
+ }
+
+ desired.agents = nil
+ desired.mcpServers = nil
+ desired.skills[0].spec.Description = "Updated review skill"
+ if err := syncer.Sync(context.Background()); err != nil {
+ t.Fatalf("third Sync() error = %v", err)
+ }
+ assertAgentSkillStoreCounts(t, agentStore, skillStore, mcpStore, 0, 1, 0)
+ if triggered[aghconfig.AgentResourceKind] != 2 ||
+ triggered[skillspkg.SkillResourceKind] != 2 ||
+ triggered[aghconfig.MCPServerResourceKind] != 2 {
+ t.Fatalf("triggered after replacement = %#v, want stale-delete/update triggers", triggered)
+ }
+}
+
+func TestAppendAgentAndSkillResourcesPublishesMCPAttachments(t *testing.T) {
+ t.Parallel()
+
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-append"}
+ decl := hookspkg.HookDecl{
+ Name: "tool-hook",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceAgentDefinition,
+ Mode: hookspkg.HookModeSync,
+ Command: "echo",
+ Args: []string{"ok"},
+ }
+ desired := agentSkillDesiredResources{}
+ appendAgentResources(&desired, scope, "append", []aghconfig.AgentDef{{
+ Name: "coder",
+ Prompt: "Prompt",
+ MCPServers: []aghconfig.MCPServer{{
+ Name: "agent-mcp",
+ Command: "agent-command",
+ }},
+ Hooks: []hookspkg.HookDecl{decl},
+ }})
+ appendSkillResources(&desired, scope, "append", []*skillspkg.Skill{{
+ Meta: skillspkg.SkillMeta{
+ Name: "review",
+ Description: "Review skill",
+ },
+ Source: skillspkg.SourceWorkspace,
+ Enabled: true,
+ MCPServers: []skillspkg.MCPServerDecl{{
+ Name: "skill-mcp",
+ Command: "skill-command",
+ }},
+ Hooks: []hookspkg.HookDecl{{
+ Name: "skill-hook",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceSkill,
+ Mode: hookspkg.HookModeSync,
+ Command: "echo",
+ }},
+ }})
+
+ if got, want := len(desired.agents), 1; got != want {
+ t.Fatalf("len(agents) = %d, want %d", got, want)
+ }
+ if got, want := len(desired.skills), 1; got != want {
+ t.Fatalf("len(skills) = %d, want %d", got, want)
+ }
+ if got, want := len(desired.mcpServers), 2; got != want {
+ t.Fatalf("len(mcpServers) = %d, want %d", got, want)
+ }
+ if desired.mcpServers[0].spec.Name != "agent-mcp" || desired.mcpServers[1].spec.Name != "skill-mcp" {
+ t.Fatalf("mcpServers = %#v, want agent and skill MCP attachments", desired.mcpServers)
+ }
+}
+
+func agentSkillSyncStores(
+ t *testing.T,
+) (
+ resources.Store[aghconfig.AgentDef],
+ resources.KindCodec[aghconfig.AgentDef],
+ resources.Store[skillspkg.SkillResourceSpec],
+ resources.KindCodec[skillspkg.SkillResourceSpec],
+ resources.Store[aghconfig.MCPServer],
+ resources.KindCodec[aghconfig.MCPServer],
+) {
+ t.Helper()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ agentCodec, err := aghconfig.NewAgentResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewAgentResourceCodec() error = %v", err)
+ }
+ agentStore, err := resources.NewStore(kernel, agentCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(agent) error = %v", err)
+ }
+ skillCodec, err := skillspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("skillspkg.NewResourceCodec() error = %v", err)
+ }
+ skillStore, err := resources.NewStore(kernel, skillCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(skill) error = %v", err)
+ }
+ mcpCodec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err)
+ }
+ mcpStore, err := resources.NewStore(kernel, mcpCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(mcp) error = %v", err)
+ }
+ return agentStore, agentCodec, skillStore, skillCodec, mcpStore, mcpCodec
+}
+
+func assertAgentSkillStoreCounts(
+ t *testing.T,
+ agentStore resources.Store[aghconfig.AgentDef],
+ skillStore resources.Store[skillspkg.SkillResourceSpec],
+ mcpStore resources.Store[aghconfig.MCPServer],
+ wantAgents int,
+ wantSkills int,
+ wantMCP int,
+) {
+ t.Helper()
+
+ source := agentSkillSyncActor().Source
+ agents, err := agentStore.List(
+ context.Background(),
+ agentSkillSyncActor(),
+ resources.ResourceFilter{Source: &source},
+ )
+ if err != nil {
+ t.Fatalf("agentStore.List() error = %v", err)
+ }
+ skills, err := skillStore.List(
+ context.Background(),
+ agentSkillSyncActor(),
+ resources.ResourceFilter{Source: &source},
+ )
+ if err != nil {
+ t.Fatalf("skillStore.List() error = %v", err)
+ }
+ servers, err := mcpStore.List(
+ context.Background(),
+ agentSkillSyncActor(),
+ resources.ResourceFilter{Source: &source},
+ )
+ if err != nil {
+ t.Fatalf("mcpStore.List() error = %v", err)
+ }
+ if len(agents) != wantAgents || len(skills) != wantSkills || len(servers) != wantMCP {
+ t.Fatalf(
+ "store counts = agents:%d skills:%d mcp:%d, want agents:%d skills:%d mcp:%d",
+ len(agents),
+ len(skills),
+ len(servers),
+ wantAgents,
+ wantSkills,
+ wantMCP,
+ )
+ }
+}
diff --git a/internal/daemon/automation_resources.go b/internal/daemon/automation_resources.go
new file mode 100644
index 000000000..9102638cc
--- /dev/null
+++ b/internal/daemon/automation_resources.go
@@ -0,0 +1,152 @@
+package daemon
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+type automationResourceProjectorTarget interface {
+ BuildJobResourceState(context.Context, []resources.Record[automationpkg.Job]) (resources.ProjectionPlan, error)
+ ApplyJobResourceState(context.Context, resources.ProjectionPlan) error
+ BuildTriggerResourceState(
+ context.Context,
+ []resources.Record[automationpkg.Trigger],
+ ) (resources.ProjectionPlan, error)
+ ApplyTriggerResourceState(context.Context, resources.ProjectionPlan) error
+}
+
+func automationResourceTarget(runtime automationRuntime) automationResourceProjectorTarget {
+ if runtime == nil {
+ return nil
+ }
+ target, ok := runtime.(automationResourceProjectorTarget)
+ if !ok {
+ return nil
+ }
+ return target
+}
+
+type automationJobProjector struct {
+ target automationResourceProjectorTarget
+}
+
+var _ resources.TypedProjector[automationpkg.Job] = (*automationJobProjector)(nil)
+
+func newAutomationJobProjector(target automationResourceProjectorTarget) resources.TypedProjector[automationpkg.Job] {
+ if target == nil {
+ return nil
+ }
+ return &automationJobProjector{target: target}
+}
+
+func (p *automationJobProjector) Kind() resources.ResourceKind {
+ return automationpkg.JobResourceKind
+}
+
+func (p *automationJobProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *automationJobProjector) Build(
+ ctx context.Context,
+ records []resources.Record[automationpkg.Job],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.target == nil {
+ return nil, errors.New("daemon: automation job projector target is required")
+ }
+ return p.target.BuildJobResourceState(ctx, records)
+}
+
+func (p *automationJobProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.target == nil {
+ return errors.New("daemon: automation job projector target is required")
+ }
+ return p.target.ApplyJobResourceState(ctx, plan)
+}
+
+type automationTriggerProjector struct {
+ target automationResourceProjectorTarget
+}
+
+var _ resources.TypedProjector[automationpkg.Trigger] = (*automationTriggerProjector)(nil)
+
+func newAutomationTriggerProjector(
+ target automationResourceProjectorTarget,
+) resources.TypedProjector[automationpkg.Trigger] {
+ if target == nil {
+ return nil
+ }
+ return &automationTriggerProjector{target: target}
+}
+
+func (p *automationTriggerProjector) Kind() resources.ResourceKind {
+ return automationpkg.TriggerResourceKind
+}
+
+func (p *automationTriggerProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *automationTriggerProjector) Build(
+ ctx context.Context,
+ records []resources.Record[automationpkg.Trigger],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.target == nil {
+ return nil, errors.New("daemon: automation trigger projector target is required")
+ }
+ return p.target.BuildTriggerResourceState(ctx, records)
+}
+
+func (p *automationTriggerProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.target == nil {
+ return errors.New("daemon: automation trigger projector target is required")
+ }
+ return p.target.ApplyTriggerResourceState(ctx, plan)
+}
+
+func automationResourceStores(
+ raw resources.RawStore,
+ codecs *resources.CodecRegistry,
+) (
+ resources.Store[automationpkg.Job],
+ resources.Store[automationpkg.Trigger],
+ error,
+) {
+ if raw == nil && codecs == nil {
+ return nil, nil, nil
+ }
+ if raw == nil {
+ return nil, nil, errors.New(
+ "daemon: automation resource raw store is required when codec registry is configured",
+ )
+ }
+ if codecs == nil {
+ return nil, nil, errors.New(
+ "daemon: automation resource codec registry is required when raw store is configured",
+ )
+ }
+
+ jobCodec, err := resources.ResolveCodec[automationpkg.Job](codecs, automationpkg.JobResourceKind)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: resolve automation job codec: %w", err)
+ }
+ jobStore, err := resources.NewStore(raw, jobCodec)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: create automation job store: %w", err)
+ }
+
+ triggerCodec, err := resources.ResolveCodec[automationpkg.Trigger](codecs, automationpkg.TriggerResourceKind)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: resolve automation trigger codec: %w", err)
+ }
+ triggerStore, err := resources.NewStore(raw, triggerCodec)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: create automation trigger store: %w", err)
+ }
+
+ return jobStore, triggerStore, nil
+}
diff --git a/internal/daemon/automation_resources_test.go b/internal/daemon/automation_resources_test.go
new file mode 100644
index 000000000..45e66e78b
--- /dev/null
+++ b/internal/daemon/automation_resources_test.go
@@ -0,0 +1,65 @@
+package daemon
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+type automationRuntimeOnlyStub struct {
+ automationRuntime
+}
+
+type automationRuntimeTargetStub struct {
+ automationRuntime
+ automationResourceProjectorTarget
+}
+
+type rawStoreStub struct {
+ resources.RawStore
+}
+
+var _ automationRuntime = automationRuntimeOnlyStub{}
+var _ automationRuntime = automationRuntimeTargetStub{}
+var _ automationResourceProjectorTarget = automationRuntimeTargetStub{}
+var _ resources.RawStore = rawStoreStub{}
+
+func TestAutomationResourceTargetReturnsProjectorTargetOnlyWhenSupported(t *testing.T) {
+ t.Parallel()
+
+ if got := automationResourceTarget(nil); got != nil {
+ t.Fatalf("automationResourceTarget(nil) = %#v, want nil", got)
+ }
+ if got := automationResourceTarget(automationRuntimeOnlyStub{}); got != nil {
+ t.Fatalf("automationResourceTarget(runtime without projector target) = %#v, want nil", got)
+ }
+ if got := automationResourceTarget(automationRuntimeTargetStub{}); got == nil {
+ t.Fatal("automationResourceTarget(runtime with projector target) = nil, want target")
+ }
+}
+
+func TestAutomationResourceStoresRejectsPartialWiring(t *testing.T) {
+ t.Parallel()
+
+ if jobs, triggers, err := automationResourceStores(nil, nil); err != nil || jobs != nil || triggers != nil {
+ t.Fatalf(
+ "automationResourceStores(nil, nil) = (%#v, %#v, %v), want (nil, nil, nil)",
+ jobs,
+ triggers,
+ err,
+ )
+ }
+
+ if _, _, err := automationResourceStores(nil, resources.NewCodecRegistry()); err == nil {
+ t.Fatal("automationResourceStores(nil, codecs) error = nil, want missing raw store failure")
+ } else if !strings.Contains(err.Error(), "raw store is required") {
+ t.Fatalf("automationResourceStores(nil, codecs) error = %v, want raw store context", err)
+ }
+
+ if _, _, err := automationResourceStores(rawStoreStub{}, nil); err == nil {
+ t.Fatal("automationResourceStores(raw, nil) error = nil, want missing codec registry failure")
+ } else if !strings.Contains(err.Error(), "codec registry is required") {
+ t.Fatalf("automationResourceStores(raw, nil) error = %v, want codec registry context", err)
+ }
+}
diff --git a/internal/daemon/boot.go b/internal/daemon/boot.go
index c9b0115c5..19d0c0129 100644
--- a/internal/daemon/boot.go
+++ b/internal/daemon/boot.go
@@ -15,6 +15,9 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
bundlepkg "github.com/pedronauck/agh/internal/bundles"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/environment/daytona"
+ "github.com/pedronauck/agh/internal/environment/local"
extensionpkg "github.com/pedronauck/agh/internal/extension"
hookspkg "github.com/pedronauck/agh/internal/hooks"
aghlogger "github.com/pedronauck/agh/internal/logger"
@@ -22,47 +25,61 @@ import (
"github.com/pedronauck/agh/internal/memory/consolidation"
"github.com/pedronauck/agh/internal/network"
"github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/skills/bundled"
taskpkg "github.com/pedronauck/agh/internal/task"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
type bootState struct {
- cfg aghconfig.Config
- logger *slog.Logger
- closeLogger func() error
- lock *Lock
- memoryStore *memory.Store
- skillsRegistry *skills.Registry
- mcpResolver *skills.MCPResolver
- dreamSvc consolidation.Service
- dreamRuntime *consolidation.Runtime
- globalMemoryDir string
- promptAssembler session.PromptAssembler
- notifier *hooksNotifier
- registry Registry
- workspaceResolver *workspacepkg.Resolver
- sessions SessionManager
- tasks *taskRuntime
- network networkRuntime
- observer Observer
- lifecycleObservers *sessionLifecycleFanout
- hookTelemetrySinks *hookTelemetryFanout
- hooks hookRuntime
- extMu sync.RWMutex
- extensions extensionRuntime
- automation automationRuntime
- bridges *bridgeRuntime
- bundles *bundlepkg.Service
- httpServer Server
- udsServer Server
- skillsCancel context.CancelFunc
- skillsDone chan struct{}
- startedAt time.Time
- info Info
- deps RuntimeDeps
+ cfg aghconfig.Config
+ logger *slog.Logger
+ closeLogger func() error
+ lock *Lock
+ memoryStore *memory.Store
+ skillsRegistry *skills.Registry
+ mcpResolver *skills.MCPResolver
+ dreamSvc consolidation.Service
+ dreamRuntime *consolidation.Runtime
+ globalMemoryDir string
+ promptAssembler session.PromptAssembler
+ notifier *hooksNotifier
+ registry Registry
+ environmentRegistry *environment.Registry
+ workspaceResolver *workspacepkg.Resolver
+ sessions SessionManager
+ tasks *taskRuntime
+ network networkRuntime
+ observer Observer
+ lifecycleObservers *sessionLifecycleFanout
+ hookTelemetrySinks *hookTelemetryFanout
+ hooks hookRuntime
+ hookDispatcher *hookspkg.Hooks
+ hookBindings hookBindingPublisher
+ resourceKernel *resources.Kernel
+ resourceCodecs *resources.CodecRegistry
+ agentCatalog *resourceCatalog[aghconfig.AgentDef]
+ toolCatalog *resourceCatalog[toolspkg.Tool]
+ mcpServerCatalog *resourceCatalog[aghconfig.MCPServer]
+ agentSkillResources agentSkillPublisher
+ toolMCPResources toolMCPPublisher
+ bundleResources bundleResourcePublisher
+ extMu sync.RWMutex
+ extensions extensionRuntime
+ resourceReconcile resources.ReconcileDriver
+ automation automationRuntime
+ bridges *bridgeRuntime
+ bundles *bundlepkg.Service
+ httpServer Server
+ udsServer Server
+ skillsCancel context.CancelFunc
+ skillsDone chan struct{}
+ startedAt time.Time
+ info Info
+ deps RuntimeDeps
}
func (s *bootState) currentExtensionRuntime() extensionRuntime {
@@ -140,15 +157,18 @@ func (d *Daemon) boot(ctx context.Context) (err error) {
if err := d.bootHooks(ctx, state, cleanup); err != nil {
return err
}
- if err := d.bootExtensions(ctx, state, cleanup); err != nil {
- return err
- }
if err := d.bootAutomation(ctx, state, cleanup); err != nil {
return err
}
if err := d.bootBundles(ctx, state); err != nil {
return err
}
+ if err := d.bootResourceReconcile(ctx, state, cleanup); err != nil {
+ return err
+ }
+ if err := d.bootExtensions(ctx, state, cleanup); err != nil {
+ return err
+ }
if err := d.bootServers(ctx, state, cleanup); err != nil {
return err
}
@@ -170,6 +190,7 @@ func (d *Daemon) beginBoot() error {
d.sessions != nil ||
d.network != nil ||
d.observer != nil ||
+ d.resourceReconcile != nil ||
d.automation != nil ||
d.bridges != nil {
return errors.New("daemon: already booted")
@@ -223,7 +244,7 @@ func (d *Daemon) bootConfig(state *bootState, cleanup *bootCleanup) error {
return nil
}
-func (d *Daemon) bootPromptProviders(ctx context.Context, state *bootState) error {
+func (d *Daemon) bootPromptProviders(_ context.Context, state *bootState) error {
var prependProviders []session.PromptProvider
var appendProviders []session.PromptProvider
@@ -246,9 +267,6 @@ func (d *Daemon) bootPromptProviders(ctx context.Context, state *bootState) erro
}
state.skillsRegistry = skills.NewRegistry(skillsCfg, skills.WithLogger(state.logger))
- if err := state.skillsRegistry.LoadAll(ctx); err != nil {
- return fmt.Errorf("daemon: load skills registry: %w", err)
- }
state.mcpResolver = skills.NewMCPResolver(state.cfg.Skills, state.logger)
appendProviders = append(appendProviders, skills.NewCatalogProvider(state.skillsRegistry))
}
@@ -270,7 +288,10 @@ func (d *Daemon) bootRuntime(ctx context.Context, state *bootState, cleanup *boo
if err := d.bootRuntimeServices(ctx, state, cleanup); err != nil {
return err
}
- return d.attachRuntimeObserver(ctx, state)
+ if err := d.attachRuntimeObserver(ctx, state); err != nil {
+ return err
+ }
+ return nil
}
func (d *Daemon) bootLockAndSocket(ctx context.Context, state *bootState, cleanup *bootCleanup) error {
@@ -361,13 +382,51 @@ func (d *Daemon) bootRuntimeServices(
state.startedAt = d.now().UTC()
state.notifier = newHooksNotifier(state.logger, d.now)
+ environmentRegistry, err := d.buildEnvironmentRegistry(state)
+ if err != nil {
+ return err
+ }
+ state.environmentRegistry = environmentRegistry
state.bridges = d.composeBridgeRuntime(state, cleanup)
+
+ resourceKernel, err := d.buildResourceKernel(state.registry)
+ if err != nil {
+ return err
+ }
+ state.resourceKernel = resourceKernel
+ state.resourceCodecs, err = d.buildResourceCodecs(state.bridges)
+ if err != nil {
+ return err
+ }
+ bridgeResources, err := bridgeInstanceResourceStore(resourceRawStore(resourceKernel), state.resourceCodecs)
+ if err != nil {
+ return err
+ }
+ if state.bridges != nil && bridgeResources != nil {
+ state.bridges.setResourceDefinitions(
+ bridgeResources,
+ resourceReconcileActor(),
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ )
+ }
+ state.agentCatalog = newResourceCatalog(cloneAgentDef)
+
sessions, err := d.newSessionManager(ctx, d.sessionManagerDeps(state))
if err != nil {
return fmt.Errorf("daemon: create session manager: %w", err)
}
state.sessions = sessions
state.deps = d.runtimeDeps(state, sessions)
+ resourceService, err := d.buildResourceService(state)
+ if err != nil {
+ return err
+ }
+ state.deps.Resources = resourceService
return nil
}
@@ -378,17 +437,34 @@ func (d *Daemon) sessionManagerDeps(state *bootState) SessionManagerDeps {
Notifier: d.sessionNotifier(state),
Hooks: session.HookSet{
Session: state.notifier,
+ Environment: state.notifier,
Prompt: state.notifier,
Events: state.notifier,
Agent: state.notifier,
Conversation: state.notifier,
Compaction: state.notifier,
},
- PromptAssembler: state.promptAssembler,
- SkillRegistry: skillRegistryDependency(state.skillsRegistry),
- MCPResolver: mcpResolverDependency(state.mcpResolver),
- WorkspaceResolver: state.workspaceResolver,
+ PromptAssembler: state.promptAssembler,
+ AgentResolver: agentCatalogDependency(state.agentCatalog),
+ SkillRegistry: skillRegistryDependency(state.skillsRegistry),
+ MCPResolver: mcpResolverDependency(state.mcpResolver),
+ WorkspaceResolver: state.workspaceResolver,
+ EnvironmentRegistry: state.environmentRegistry,
+ }
+}
+
+func (d *Daemon) buildEnvironmentRegistry(state *bootState) (*environment.Registry, error) {
+ if state == nil {
+ return nil, errors.New("daemon: environment registry state is required")
+ }
+ registry, err := local.NewRegistry(local.WithLogger(state.logger))
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create environment registry: %w", err)
}
+ if err := registry.Register(daytona.NewProvider(daytona.WithLogger(state.logger))); err != nil {
+ return nil, fmt.Errorf("daemon: register daytona environment provider: %w", err)
+ }
+ return registry, nil
}
func (d *Daemon) sessionNotifier(state *bootState) session.Notifier {
@@ -447,6 +523,7 @@ func (d *Daemon) runtimeDeps(state *bootState, sessions SessionManager) RuntimeD
MemoryStore: state.memoryStore,
WorkspaceResolver: state.workspaceResolver,
WorkspaceService: state.workspaceResolver,
+ AgentCatalog: agentCatalogDependency(state.agentCatalog),
SkillsRegistry: skillsRegistryAPI(state.skillsRegistry),
DreamTrigger: dreamTriggerFromRuntime(state.dreamRuntime),
StartedAt: state.startedAt,
@@ -467,6 +544,115 @@ func dreamTriggerFromRuntime(runtime *consolidation.Runtime) DreamTrigger {
return runtime
}
+func (d *Daemon) buildResourceKernel(registry Registry) (*resources.Kernel, error) {
+ if registry == nil {
+ return nil, errors.New("daemon: resource service registry is required")
+ }
+
+ dbSource, ok := registry.(extensionDBSource)
+ if !ok || dbSource.DB() == nil {
+ return nil, nil
+ }
+
+ kernel, err := resources.NewKernel(dbSource.DB())
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create resource kernel: %w", err)
+ }
+ return kernel, nil
+}
+
+func (d *Daemon) buildResourceCodecs(bridges *bridgeRuntime) (*resources.CodecRegistry, error) {
+ registry := resources.NewCodecRegistry()
+ if err := registerDaemonResourceCodecs(registry, bridges); err != nil {
+ return nil, err
+ }
+ return registry, nil
+}
+
+func registerDaemonResourceCodecs(registry *resources.CodecRegistry, bridges *bridgeRuntime) error {
+ if err := registerDaemonResourceCodec(registry, "hook binding", newHookBindingCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "tool", toolspkg.NewResourceCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "mcp server", aghconfig.NewMCPServerResourceCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "agent", aghconfig.NewAgentResourceCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "skill", skills.NewResourceCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "automation job", automationpkg.NewJobResourceCodec); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(
+ registry,
+ "automation trigger",
+ automationpkg.NewTriggerResourceCodec,
+ ); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "bridge instance", func() (
+ resources.KindCodec[bridgepkg.BridgeInstanceSpec],
+ error,
+ ) {
+ return bridgepkg.NewBridgeInstanceResourceCodec(bridgeProviderLookup(bridges))
+ }); err != nil {
+ return err
+ }
+ if err := registerDaemonResourceCodec(registry, "bundle", bundlepkg.NewBundleResourceCodec); err != nil {
+ return err
+ }
+ return registerDaemonResourceCodec(
+ registry,
+ "bundle activation",
+ bundlepkg.NewActivationResourceCodec,
+ )
+}
+
+func registerDaemonResourceCodec[T any](
+ registry *resources.CodecRegistry,
+ label string,
+ build func() (resources.KindCodec[T], error),
+) error {
+ codec, err := build()
+ if err != nil {
+ return fmt.Errorf("daemon: build %s codec: %w", label, err)
+ }
+ if err := resources.RegisterCodec(registry, codec); err != nil {
+ return fmt.Errorf("daemon: register %s codec: %w", label, err)
+ }
+ return nil
+}
+
+func (d *Daemon) buildResourceService(state *bootState) (core.ResourceService, error) {
+ if state == nil {
+ return nil, nil
+ }
+ rawStore := resourceRawStore(state.resourceKernel)
+ if rawStore == nil {
+ return nil, nil
+ }
+
+ service, err := core.NewOperatorResourceService(&core.ResourceServiceConfig{
+ RawStore: rawStore,
+ CodecRegistry: state.resourceCodecs,
+ Trigger: func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state == nil || state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create resource service: %w", err)
+ }
+ return service, nil
+}
+
func (d *Daemon) attachRuntimeObserver(ctx context.Context, state *bootState) error {
observer, err := d.newObserver(ctx, state.deps)
if err != nil {
@@ -477,6 +663,52 @@ func (d *Daemon) attachRuntimeObserver(ctx context.Context, state *bootState) er
return nil
}
+func (d *Daemon) bootResourceReconcile(ctx context.Context, state *bootState, cleanup *bootCleanup) error {
+ if state == nil {
+ return errors.New("daemon: reconcile boot state is required")
+ }
+ if d.newResourceReconcile == nil {
+ return errors.New("daemon: resource reconcile driver factory is required")
+ }
+ if state.agentCatalog == nil {
+ state.agentCatalog = newResourceCatalog(cloneAgentDef)
+ }
+ if state.toolCatalog == nil {
+ state.toolCatalog = newResourceCatalog(cloneToolSpec)
+ }
+ if state.mcpServerCatalog == nil {
+ state.mcpServerCatalog = newResourceCatalog(cloneDaemonMCPServer)
+ }
+
+ driver, err := d.newResourceReconcile(ctx, resourceReconcileDriverDeps{
+ Config: state.cfg,
+ Logger: state.logger,
+ Registry: state.registry,
+ ResourceStore: resourceRawStore(state.resourceKernel),
+ CodecRegistry: state.resourceCodecs,
+ Hooks: state.hookDispatcher,
+ AgentCatalog: state.agentCatalog,
+ ToolCatalog: state.toolCatalog,
+ MCPServerCatalog: state.mcpServerCatalog,
+ SkillsRegistry: state.skillsRegistry,
+ Automation: automationResourceTarget(state.automation),
+ Bridges: bridgeResourceTarget(state.bridges),
+ Bundles: state.bundles,
+ })
+ if err != nil {
+ return fmt.Errorf("daemon: create resource reconcile driver: %w", err)
+ }
+ if driver == nil {
+ return errors.New("daemon: resource reconcile driver factory returned nil")
+ }
+
+ state.resourceReconcile = driver
+ cleanup.add(func(ctx context.Context) error {
+ return driver.Close(ctx)
+ })
+ return nil
+}
+
func (d *Daemon) bootNetwork(ctx context.Context, state *bootState, cleanup *bootCleanup) error {
if state == nil {
return errors.New("daemon: boot network state is required")
@@ -518,45 +750,21 @@ func (d *Daemon) bootNetwork(ctx context.Context, state *bootState, cleanup *boo
}
func (d *Daemon) bootHooks(ctx context.Context, state *bootState, cleanup *bootCleanup) error {
- state.lifecycleObservers = newSessionLifecycleFanout()
- if state.observer != nil {
- state.lifecycleObservers.Add(state.observer)
- }
- state.hookTelemetrySinks = newHookTelemetryFanout()
- if sink, ok := state.observer.(hookspkg.TelemetrySink); ok {
- state.hookTelemetrySinks.Add(sink)
+ if state == nil {
+ return errors.New("daemon: hook boot state is required")
}
- nativeDecls, nativeExecutors := daemonNativeHooks(state.lifecycleObservers, state.dreamRuntime)
- hookOptions := []hookspkg.Option{
- hookspkg.WithLogger(state.logger),
- hookspkg.WithNow(d.now),
- hookspkg.WithDebugPatchAudit(strings.EqualFold(state.cfg.Log.Level, "debug")),
- hookspkg.WithExecutorResolver(daemonExecutorResolver(nativeExecutors)),
- hookspkg.WithNativeDeclarations(nativeDecls),
- hookspkg.WithConfigDeclarationProvider(chainDeclarationProviders(
- configDeclarationProvider(state.registry, state.workspaceResolver, state.logger),
- extensionDeclarationProvider(state.currentExtensionRuntime),
- )),
- hookspkg.WithAgentDeclarationProvider(
- agentDeclarationProvider(state.registry, state.workspaceResolver, state.logger),
- ),
- hookspkg.WithSkillDeclarationProvider(
- skillDeclarationProvider(
- state.skillsRegistry,
- state.registry,
- state.workspaceResolver,
- state.cfg.Skills.AllowedMarketplaceHooks,
- state.logger,
- ),
- ),
- hookspkg.WithTelemetrySink(state.hookTelemetrySinks),
+ nativeDecls, nativeExecutors := d.initializeHookObservers(state)
+ providers := d.hookBindingProviders(state, nativeDecls)
+ hooks := hookspkg.NewHooks(d.hookRuntimeOptions(state, nativeExecutors)...)
+ hookBindings, err := d.newHookBindingPublisher(state, hooks, providers)
+ if err != nil {
+ hooks.Close()
+ return err
}
-
- hooks := hookspkg.NewHooks(hookOptions...)
- if err := hooks.Rebuild(ctx); err != nil {
+ if err := hookBindings.Sync(ctx); err != nil {
hooks.Close()
- return fmt.Errorf("daemon: rebuild hooks: %w", err)
+ return fmt.Errorf("daemon: sync hook bindings: %w", err)
}
if hookAwareObserver, ok := state.observer.(interface {
AttachHooks(observe.HookCatalogSource)
@@ -575,7 +783,12 @@ func (d *Daemon) bootHooks(ctx context.Context, state *bootState, cleanup *bootC
state.skillsRegistry,
state.cfg.Skills.PollInterval,
func(refreshCtx context.Context) error {
- return hooks.Rebuild(refreshCtx)
+ if state.agentSkillResources != nil {
+ if err := state.agentSkillResources.Sync(refreshCtx); err != nil {
+ return err
+ }
+ }
+ return hookBindings.Sync(refreshCtx)
},
)
cleanup.add(func(context.Context) error {
@@ -585,9 +798,100 @@ func (d *Daemon) bootHooks(ctx context.Context, state *bootState, cleanup *bootC
}
state.hooks = hooks
+ state.hookDispatcher = hooks
+ state.hookBindings = hookBindings
return nil
}
+func (d *Daemon) initializeHookObservers(state *bootState) ([]hookspkg.HookDecl, map[string]hookspkg.Executor) {
+ state.lifecycleObservers = newSessionLifecycleFanout()
+ if state.observer != nil {
+ state.lifecycleObservers.Add(state.observer)
+ }
+ state.hookTelemetrySinks = newHookTelemetryFanout()
+ if sink, ok := state.observer.(hookspkg.TelemetrySink); ok {
+ state.hookTelemetrySinks.Add(sink)
+ }
+ return daemonNativeHooks(state.lifecycleObservers, state.dreamRuntime)
+}
+
+func (d *Daemon) hookBindingProviders(
+ state *bootState,
+ nativeDecls []hookspkg.HookDecl,
+) []hookBindingDeclarationProvider {
+ return []hookBindingDeclarationProvider{
+ func(context.Context) ([]hookspkg.HookDecl, error) {
+ return hookCloneDeclarations(nativeDecls), nil
+ },
+ configDeclarationProvider(state.registry, state.workspaceResolver, state.logger),
+ agentDeclarationProvider(state.registry, state.workspaceResolver, state.logger),
+ skillDeclarationProvider(
+ state.skillsRegistry,
+ state.registry,
+ state.workspaceResolver,
+ state.cfg.Skills.AllowedMarketplaceHooks,
+ state.logger,
+ ),
+ extensionDeclarationProvider(state.currentExtensionRuntime),
+ }
+}
+
+func (d *Daemon) hookRuntimeOptions(
+ state *bootState,
+ nativeExecutors map[string]hookspkg.Executor,
+) []hookspkg.Option {
+ return []hookspkg.Option{
+ hookspkg.WithLogger(state.logger),
+ hookspkg.WithNow(d.now),
+ hookspkg.WithDebugPatchAudit(strings.EqualFold(state.cfg.Log.Level, "debug")),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(nativeExecutors)),
+ hookspkg.WithTelemetrySink(state.hookTelemetrySinks),
+ }
+}
+
+func (d *Daemon) newHookBindingPublisher(
+ state *bootState,
+ hooks *hookspkg.Hooks,
+ providers []hookBindingDeclarationProvider,
+) (hookBindingPublisher, error) {
+ hookBindings := hookBindingPublisher(hookBindingPublisherFunc(func(reloadCtx context.Context) error {
+ decls, err := chainDeclarationProviders(providers...)(reloadCtx)
+ if err != nil {
+ return err
+ }
+ nextState, err := hooks.BuildBindingState(decls)
+ if err != nil {
+ return err
+ }
+ return hooks.ApplyBindingState(nextState, 0)
+ }))
+ if state.resourceKernel == nil || state.resourceCodecs == nil {
+ return hookBindings, nil
+ }
+
+ hookCodec, err := resources.ResolveCodec[hookspkg.HookDecl](state.resourceCodecs, hookBindingResourceKind)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve hook binding codec: %w", err)
+ }
+ hookStore, err := newHookBindingStore(state.resourceKernel, hookCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create hook binding store: %w", err)
+ }
+ return newHookBindingSourceSyncer(
+ hookStore,
+ hookCodec,
+ hookBindingSyncActor(),
+ state.logger,
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ providers...,
+ ), nil
+}
+
func (d *Daemon) bootAutomation(ctx context.Context, state *bootState, cleanup *bootCleanup) error {
if state == nil {
return nil
@@ -619,6 +923,14 @@ func (d *Daemon) bootAutomation(ctx context.Context, state *bootState, cleanup *
Hooks: state.hooks,
Logger: state.logger.With("component", "automation"),
GlobalWorkspacePath: d.homePaths.HomeDir,
+ ResourceStore: resourceRawStore(state.resourceKernel),
+ ResourceCodecs: state.resourceCodecs,
+ ResourceTrigger: func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
})
if err != nil {
return fmt.Errorf("daemon: create automation manager: %w", err)
@@ -645,14 +957,12 @@ func (d *Daemon) bootAutomation(ctx context.Context, state *bootState, cleanup *
return nil
}
-func (d *Daemon) bootBundles(ctx context.Context, state *bootState) error {
+func (d *Daemon) bootBundles(_ context.Context, state *bootState) error {
if state == nil {
return errors.New("daemon: boot bundle state is required")
}
dbSource, ok := state.registry.(interface {
- bundlepkg.Store
- bridgepkg.ManagedSyncStore
extensionDBSource
})
if !ok {
@@ -660,15 +970,19 @@ func (d *Daemon) bootBundles(ctx context.Context, state *bootState) error {
}
extRegistry := extensionpkg.NewRegistry(dbSource.DB())
- bridgeSyncer := bridgepkg.NewManagedSyncer(dbSource, bridgepkg.WithManagedSyncNow(d.now))
+ resourceStore, err := newBundleResourceStore(state, d.now)
+ if err != nil {
+ return err
+ }
+ if resourceStore == nil {
+ return nil
+ }
service := bundlepkg.NewService(
- dbSource,
+ resourceStore,
extRegistry,
func(name string) (*extensionpkg.Extension, error) {
return loadExtensionSnapshot(extRegistry, state.currentExtensionRuntime(), state.logger, name)
},
- bundlepkg.WithAutomation(state.automation),
- bundlepkg.WithBridges(bridgeSyncer),
bundlepkg.WithWorkspaceResolver(state.workspaceResolver),
bundlepkg.WithConfiguredDefaultChannel(state.cfg.Network.DefaultChannel),
bundlepkg.WithLogger(state.logger),
@@ -677,14 +991,8 @@ func (d *Daemon) bootBundles(ctx context.Context, state *bootState) error {
if service == nil {
return nil
}
- if err := service.Reconcile(ctx); err != nil {
- return fmt.Errorf("daemon: reconcile bundle activations: %w", err)
- }
state.bundles = service
state.deps.Bundles = service
- if extRegistry, ok := state.deps.Extensions.(*daemonExtensionService); ok {
- extRegistry.bundles = service
- }
return nil
}
@@ -700,26 +1008,13 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup *
}
extRegistry := extensionpkg.NewRegistry(dbSource.DB())
- manager := d.newExtensionManager(extensionManagerDeps{
- Registry: extRegistry,
- Sessions: state.sessions,
- Automation: func() extensionpkg.HostAPIAutomationManager {
- return state.automation
- },
- Tasks: state.deps.Tasks,
- MemoryStore: state.memoryStore,
- Observer: state.observer,
- SkillsRegistry: state.skillsRegistry,
- WorkspaceResolver: state.workspaceResolver,
- Logger: state.logger,
- BridgeRegistry: state.bridges,
- BridgeDedupStore: bridgeRuntimeDedupStore(state.bridges),
- BridgeBroker: bridgeRuntimeBroker(state.bridges),
- BridgeRuntime: state.bridges,
- })
+ if err := d.configureExtensionResourcePublishers(state, extRegistry); err != nil {
+ return err
+ }
+ manager := d.newExtensionManager(d.extensionManagerDeps(state, extRegistry))
if manager == nil {
state.logger.Warn("daemon: extension manager factory returned nil; skipping extensions")
- return nil
+ return syncExtensionResourcePublishers(ctx, state)
}
cleanup.add(func(ctx context.Context) error {
@@ -745,26 +1040,151 @@ func (d *Daemon) bootExtensions(ctx context.Context, state *bootState, cleanup *
state.bridges.setExtensionRuntime(manager)
}
state.setExtensionRuntime(manager)
+ d.attachExtensionRuntime(ctx, state, extRegistry, manager)
+
+ return nil
+}
+
+func (d *Daemon) configureExtensionResourcePublishers(
+ state *bootState,
+ extRegistry *extensionpkg.Registry,
+) error {
+ agentSkillResources, err := d.newAgentSkillPublisher(state, extRegistry)
+ if err != nil {
+ return err
+ }
+ state.agentSkillResources = agentSkillResources
+ toolMCPResources, err := d.newToolMCPPublisher(state, extRegistry)
+ if err != nil {
+ return err
+ }
+ state.toolMCPResources = toolMCPResources
+ bundleResources, err := d.newBundlePublisher(state, extRegistry)
+ if err != nil {
+ return err
+ }
+ state.bundleResources = bundleResources
+ return nil
+}
+
+func syncExtensionResourcePublishers(ctx context.Context, state *bootState) error {
+ if state.agentSkillResources != nil {
+ if err := state.agentSkillResources.Sync(ctx); err != nil {
+ return err
+ }
+ }
+ if state.hookBindings != nil {
+ if err := state.hookBindings.Sync(ctx); err != nil {
+ return err
+ }
+ }
+ if state.toolMCPResources != nil {
+ if err := state.toolMCPResources.Sync(ctx); err != nil {
+ return err
+ }
+ }
+ if state.bundleResources != nil {
+ if err := state.bundleResources.Sync(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (d *Daemon) extensionManagerDeps(
+ state *bootState,
+ extRegistry *extensionpkg.Registry,
+) extensionManagerDeps {
+ return extensionManagerDeps{
+ Registry: extRegistry,
+ Extensions: state.cfg.Extensions,
+ Sessions: state.sessions,
+ Automation: func() extensionpkg.HostAPIAutomationManager {
+ return state.automation
+ },
+ Tasks: state.deps.Tasks,
+ MemoryStore: state.memoryStore,
+ Observer: state.observer,
+ SkillsRegistry: state.skillsRegistry,
+ WorkspaceResolver: state.workspaceResolver,
+ Logger: state.logger,
+ BridgeRegistry: state.bridges,
+ BridgeDedupStore: bridgeRuntimeDedupStore(state.bridges),
+ BridgeBroker: bridgeRuntimeBroker(state.bridges),
+ BridgeRuntime: state.bridges,
+ ResourceStore: resourceRawStore(state.resourceKernel),
+ SourceSessions: resourceSourceSessions(state.resourceKernel),
+ ResourceCodecs: state.resourceCodecs,
+ ResourceTrigger: func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ }
+}
+
+func (d *Daemon) attachExtensionRuntime(
+ ctx context.Context,
+ state *bootState,
+ extRegistry *extensionpkg.Registry,
+ manager extensionRuntime,
+) {
state.deps.Extensions = newDaemonExtensionService(
extRegistry,
manager,
- state.hooks,
- state.bundles,
+ state.hookBindings,
+ state.agentSkillResources,
+ state.toolMCPResources,
+ state.bundleResources,
d.homePaths,
state.logger,
d.now,
)
- if state.hooks != nil {
- if err := state.hooks.Rebuild(ctx); err != nil {
- state.logger.Error(
- "daemon: rebuild hooks after extension boot failed; continuing without extension hooks",
- "error",
- err,
- )
+ if state.agentSkillResources != nil {
+ if err := state.agentSkillResources.Sync(ctx); err != nil {
+ state.logger.Error("daemon: sync agent/skill resources after extension boot failed", "error", err)
+ }
+ }
+ if state.hookBindings != nil {
+ if err := state.hookBindings.Sync(ctx); err != nil {
+ state.logger.Error("daemon: sync hook bindings after extension boot failed", "error", err)
+ }
+ }
+ if state.toolMCPResources != nil {
+ if err := state.toolMCPResources.Sync(ctx); err != nil {
+ state.logger.Error("daemon: sync tool/mcp resources after extension boot failed", "error", err)
+ }
+ }
+ if state.bundleResources != nil {
+ if err := state.bundleResources.Sync(ctx); err != nil {
+ state.logger.Error("daemon: sync bundle resources after extension boot failed", "error", err)
}
}
+ if state.hookBindings != nil {
+ return
+ }
+ if rebuildable, ok := state.hooks.(interface {
+ Rebuild(context.Context) error
+ }); ok {
+ if err := rebuildable.Rebuild(ctx); err != nil {
+ state.logger.Error("daemon: rebuild hooks after extension boot failed", "error", err)
+ }
+ }
+}
- return nil
+func resourceRawStore(kernel *resources.Kernel) resources.RawStore {
+ if kernel == nil {
+ return nil
+ }
+ return kernel
+}
+
+func resourceSourceSessions(kernel *resources.Kernel) resources.SourceSessionManager {
+ if kernel == nil {
+ return nil
+ }
+ return kernel
}
func extensionRuntimeHasRegisteredEntries(
@@ -879,6 +1299,14 @@ func daemonNetworkInfo(
}
func (d *Daemon) bootFinalize(ctx context.Context, state *bootState) error {
+ if state.resourceReconcile != nil {
+ if err := state.resourceReconcile.RunBoot(ctx); err != nil {
+ return fmt.Errorf("daemon: boot resource reconcile: %w", err)
+ }
+ }
+
+ d.reconcileDaemonEnvironments(ctx, state)
+
reconcileResult, err := state.observer.Reconcile(ctx)
if err != nil {
return fmt.Errorf("daemon: reconcile sessions: %w", err)
@@ -915,11 +1343,16 @@ func (d *Daemon) publishBootState(state *bootState) {
d.extensions = state.currentExtensionRuntime()
d.bridges = state.bridges
d.observer = state.observer
+ d.resourceReconcile = state.resourceReconcile
+ d.agentCatalog = state.agentCatalog
+ d.toolCatalog = state.toolCatalog
+ d.mcpServerCatalog = state.mcpServerCatalog
d.automation = state.automation
d.httpServer = state.httpServer
d.udsServer = state.udsServer
d.dreamRuntime = state.dreamRuntime
d.workspaceResolver = state.workspaceResolver
+ d.environmentRegistry = state.environmentRegistry
d.skillsRegistry = state.skillsRegistry
d.skillsCancel = state.skillsCancel
d.skillsDone = state.skillsDone
diff --git a/internal/daemon/bridge_resources.go b/internal/daemon/bridge_resources.go
new file mode 100644
index 000000000..473cab6a7
--- /dev/null
+++ b/internal/daemon/bridge_resources.go
@@ -0,0 +1,127 @@
+package daemon
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+type bridgeResourceProjectorTarget interface {
+ BuildBridgeResourceState(
+ context.Context,
+ []resources.Record[bridgepkg.BridgeInstanceSpec],
+ ) (resources.ProjectionPlan, error)
+ ApplyBridgeResourceState(context.Context, resources.ProjectionPlan) error
+}
+
+func bridgeResourceTarget(runtime *bridgeRuntime) bridgeResourceProjectorTarget {
+ if runtime == nil {
+ return nil
+ }
+ return runtime
+}
+
+type bridgeInstanceProjector struct {
+ target bridgeResourceProjectorTarget
+}
+
+var _ resources.TypedProjector[bridgepkg.BridgeInstanceSpec] = (*bridgeInstanceProjector)(nil)
+
+func newBridgeInstanceProjector(
+ target bridgeResourceProjectorTarget,
+) resources.TypedProjector[bridgepkg.BridgeInstanceSpec] {
+ if target == nil {
+ return nil
+ }
+ return &bridgeInstanceProjector{target: target}
+}
+
+func (p *bridgeInstanceProjector) Kind() resources.ResourceKind {
+ return bridgepkg.BridgeInstanceResourceKind
+}
+
+func (p *bridgeInstanceProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *bridgeInstanceProjector) Build(
+ ctx context.Context,
+ records []resources.Record[bridgepkg.BridgeInstanceSpec],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.target == nil {
+ return nil, errors.New("daemon: bridge instance projector target is required")
+ }
+ return p.target.BuildBridgeResourceState(ctx, records)
+}
+
+func (p *bridgeInstanceProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.target == nil {
+ return errors.New("daemon: bridge instance projector target is required")
+ }
+ return p.target.ApplyBridgeResourceState(ctx, plan)
+}
+
+func bridgeInstanceResourceStore(
+ raw resources.RawStore,
+ codecs *resources.CodecRegistry,
+) (resources.Store[bridgepkg.BridgeInstanceSpec], error) {
+ if raw == nil || codecs == nil {
+ return nil, nil
+ }
+ codec, err := resources.ResolveCodec[bridgepkg.BridgeInstanceSpec](
+ codecs,
+ bridgepkg.BridgeInstanceResourceKind,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve bridge instance codec: %w", err)
+ }
+ store, err := resources.NewStore(raw, codec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create bridge instance resource store: %w", err)
+ }
+ return store, nil
+}
+
+func bridgeProviderLookup(runtime *bridgeRuntime) bridgepkg.BridgeProviderLookup {
+ if runtime == nil {
+ return nil
+ }
+ return func(ctx context.Context, extensionName string) (bridgepkg.BridgeProvider, bool, error) {
+ providers, err := runtime.ListProviders(ctx)
+ if err != nil {
+ return bridgepkg.BridgeProvider{}, false, err
+ }
+ trimmed := strings.TrimSpace(extensionName)
+ for _, provider := range providers {
+ if strings.TrimSpace(provider.ExtensionName) == trimmed {
+ return provider, true, nil
+ }
+ }
+ return bridgepkg.BridgeProvider{}, false, nil
+ }
+}
+
+func appendBridgeProjectorRegistration(
+ registrations []resources.ProjectorRegistration,
+ deps *resourceReconcileDriverDeps,
+) ([]resources.ProjectorRegistration, error) {
+ codec, err := resources.ResolveCodec[bridgepkg.BridgeInstanceSpec](
+ deps.CodecRegistry,
+ bridgepkg.BridgeInstanceResourceKind,
+ )
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(
+ codec,
+ newBridgeInstanceProjector(deps.Bridges),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return append(registrations, registration), nil
+}
diff --git a/internal/daemon/bridges.go b/internal/daemon/bridges.go
index c58855d00..7bf4dfcde 100644
--- a/internal/daemon/bridges.go
+++ b/internal/daemon/bridges.go
@@ -13,6 +13,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionpkg "github.com/pedronauck/agh/internal/extension"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/subprocess"
)
@@ -28,6 +29,7 @@ type bridgeDedupStore interface {
type bridgeRuntimeStore interface {
bridgepkg.RegistryStore
+ bridgepkg.ResourceProjectionStore
bridgeDedupStore
PutBridgeSecretBinding(ctx context.Context, binding bridgepkg.BridgeSecretBinding) error
ListBridgeSecretBindings(ctx context.Context, bridgeInstanceID string) ([]bridgepkg.BridgeSecretBinding, error)
@@ -45,12 +47,15 @@ type BridgeSecretResolver interface {
type bridgeRuntime struct {
*bridgepkg.Service
- store bridgeRuntimeStore
- registry *extensionpkg.Registry
- secretResolver BridgeSecretResolver
- broker *bridgepkg.Broker
- logger *slog.Logger
- now func() time.Time
+ store bridgeRuntimeStore
+ registry *extensionpkg.Registry
+ secretResolver BridgeSecretResolver
+ broker *bridgepkg.Broker
+ logger *slog.Logger
+ now func() time.Time
+ resourceStore resources.Store[bridgepkg.BridgeInstanceSpec]
+ resourceActor resources.MutationActor
+ resourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
lifecycleMu sync.Mutex
lifecycleLocks map[string]*bridgeLifecycleLock
@@ -105,6 +110,23 @@ func newBridgeRuntime(
}
}
+func (r *bridgeRuntime) setResourceDefinitions(
+ store resources.Store[bridgepkg.BridgeInstanceSpec],
+ actor resources.MutationActor,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+) {
+ if r == nil {
+ return
+ }
+ r.resourceStore = store
+ r.resourceActor = actor
+ r.resourceTrigger = trigger
+}
+
+func (r *bridgeRuntime) resourceDefinitionsEnabled() bool {
+ return r != nil && r.resourceStore != nil
+}
+
func (r *bridgeRuntime) Broker() *bridgepkg.Broker {
if r == nil {
return nil
@@ -122,6 +144,9 @@ func (r *bridgeRuntime) CreateInstance(
if r == nil {
return nil, errors.New("daemon: bridge runtime is required")
}
+ if r.resourceDefinitionsEnabled() {
+ return r.createInstanceResource(ctx, req)
+ }
ctx, unlockExtension := r.lockExtensionLifecycleContext(ctx, req.ExtensionName)
defer unlockExtension()
@@ -159,6 +184,218 @@ func (r *bridgeRuntime) CreateInstance(
return created, nil
}
+func (r *bridgeRuntime) createInstanceResource(
+ ctx context.Context,
+ req bridgepkg.CreateInstanceRequest,
+) (*bridgepkg.BridgeInstance, error) {
+ id, spec, err := bridgepkg.BridgeInstanceSpecFromCreateRequest(req, r.now)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create bridge instance resource: %w", err)
+ }
+
+ ctx, unlockExtension := r.lockExtensionLifecycleContext(ctx, spec.ExtensionName)
+ defer unlockExtension()
+ ctx, unlockInstance := r.lockInstanceLifecycleContext(ctx, id)
+ defer unlockInstance()
+
+ createdRecord, err := r.resourceStore.Put(
+ ctx,
+ r.resourceActorForSource(spec.Source),
+ resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: id,
+ Scope: bridgepkg.ResourceScopeForBridge(spec.Scope, spec.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: spec,
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create bridge instance resource %q: %w", id, err)
+ }
+ if err := r.applyBridgeResourcesFromStore(ctx); err != nil {
+ return nil, r.rollbackCreatedBridgeResource(ctx, createdRecord, "create", err)
+ }
+ if err := r.triggerBridgeResourceReconcile(ctx); err != nil {
+ return nil, r.rollbackCreatedBridgeResource(ctx, createdRecord, "create", err)
+ }
+
+ created, err := r.GetInstance(ctx, id)
+ if err != nil {
+ return nil, r.rollbackCreatedBridgeResource(ctx, createdRecord, "create", err)
+ }
+ return created, nil
+}
+
+// UpdateInstance writes mutable bridge desired state through canonical resources when enabled.
+func (r *bridgeRuntime) UpdateInstance(
+ ctx context.Context,
+ req bridgepkg.UpdateInstanceRequest,
+) (*bridgepkg.BridgeInstance, error) {
+ if r == nil {
+ return nil, errors.New("daemon: bridge runtime is required")
+ }
+ if !r.resourceDefinitionsEnabled() {
+ return r.Service.UpdateInstance(ctx, req)
+ }
+ return r.updateInstanceResource(ctx, req)
+}
+
+func (r *bridgeRuntime) updateInstanceResource(
+ ctx context.Context,
+ req bridgepkg.UpdateInstanceRequest,
+) (*bridgepkg.BridgeInstance, error) {
+ if err := req.Validate(); err != nil {
+ return nil, fmt.Errorf("daemon: update bridge instance resource %q: %w", strings.TrimSpace(req.ID), err)
+ }
+
+ current, err := r.loadMutableBridgeInstanceResource(ctx, strings.TrimSpace(req.ID))
+ if err != nil {
+ return nil, err
+ }
+ next := updatedBridgeInstanceSpec(current.Spec, req)
+
+ ctx, unlockExtension := r.lockExtensionLifecycleContext(ctx, next.ExtensionName)
+ defer unlockExtension()
+ ctx, unlockInstance := r.lockInstanceLifecycleContext(ctx, current.ID)
+ defer unlockInstance()
+
+ previous, err := r.GetInstance(ctx, current.ID)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: update bridge instance resource %q: load current state: %w", current.ID, err)
+ }
+
+ updatedRecord, err := r.putBridgeInstanceResource(ctx, current, next)
+ if err != nil {
+ return nil, err
+ }
+ if err := r.applyBridgeResourcesFromStore(ctx); err != nil {
+ return nil, r.rollbackBridgeResourceState(ctx, current, updatedRecord.Version, previous, "update", err)
+ }
+ if err := r.applyBridgeUpdateOperationalState(ctx, current.ID, next.Enabled, req); err != nil {
+ return nil, r.rollbackBridgeResourceState(ctx, current, updatedRecord.Version, previous, "update", err)
+ }
+ if err := r.triggerBridgeResourceReconcile(ctx); err != nil {
+ return nil, r.rollbackBridgeResourceState(ctx, current, updatedRecord.Version, previous, "update", err)
+ }
+
+ updated, err := r.GetInstance(ctx, current.ID)
+ if err != nil {
+ return nil, r.rollbackBridgeResourceState(ctx, current, updatedRecord.Version, previous, "update", err)
+ }
+ return updated, nil
+}
+
+func (r *bridgeRuntime) loadMutableBridgeInstanceResource(
+ ctx context.Context,
+ id string,
+) (resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ current, err := r.resourceStore.Get(ctx, r.resourceActor, id)
+ if err != nil {
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{}, fmt.Errorf(
+ "daemon: update bridge instance resource %q: %w",
+ id,
+ err,
+ )
+ }
+ if current.Spec.Source == bridgepkg.BridgeInstanceSourcePackage {
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{}, fmt.Errorf(
+ "daemon: update bridge instance resource %q: %w",
+ current.ID,
+ bridgepkg.ErrBridgeInstanceReadOnly,
+ )
+ }
+ return current, nil
+}
+
+func updatedBridgeInstanceSpec(
+ current bridgepkg.BridgeInstanceSpec,
+ req bridgepkg.UpdateInstanceRequest,
+) bridgepkg.BridgeInstanceSpec {
+ next := current
+ if req.DisplayName != nil {
+ next.DisplayName = strings.TrimSpace(*req.DisplayName)
+ }
+ if req.DMPolicy != nil {
+ next.DMPolicy = req.DMPolicy.Normalize()
+ }
+ if req.RoutingPolicy != nil {
+ next.RoutingPolicy = *req.RoutingPolicy
+ }
+ if req.ProviderConfig != nil {
+ next.ProviderConfig = append([]byte(nil), (*req.ProviderConfig)...)
+ }
+ if req.DeliveryDefaults != nil {
+ next.DeliveryDefaults = append([]byte(nil), (*req.DeliveryDefaults)...)
+ }
+ return next
+}
+
+func (r *bridgeRuntime) putBridgeInstanceResource(
+ ctx context.Context,
+ current resources.Record[bridgepkg.BridgeInstanceSpec],
+ next bridgepkg.BridgeInstanceSpec,
+) (resources.Record[bridgepkg.BridgeInstanceSpec], error) {
+ record, err := r.resourceStore.Put(
+ ctx,
+ r.resourceActorForRecordSource(current.Source),
+ resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: current.ID,
+ Scope: bridgepkg.ResourceScopeForBridge(next.Scope, next.WorkspaceID),
+ ExpectedVersion: current.Version,
+ Spec: next,
+ },
+ )
+ if err != nil {
+ return resources.Record[bridgepkg.BridgeInstanceSpec]{}, fmt.Errorf(
+ "daemon: update bridge instance resource %q: %w",
+ current.ID,
+ err,
+ )
+ }
+ return record, nil
+}
+
+func (r *bridgeRuntime) applyBridgeUpdateOperationalState(
+ ctx context.Context,
+ id string,
+ enabled bool,
+ req bridgepkg.UpdateInstanceRequest,
+) error {
+ if !req.ClearDegradation && req.Degradation == nil {
+ return nil
+ }
+ currentInstance, err := r.GetInstance(ctx, id)
+ if err != nil {
+ return err
+ }
+ _, err = r.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{
+ ID: id,
+ Enabled: enabled,
+ Status: bridgeStatusForBridgeUpdate(currentInstance.Status, req),
+ Degradation: req.Degradation,
+ ClearDegradation: req.ClearDegradation,
+ UpdatedAt: r.now().UTC(),
+ })
+ return err
+}
+
+func bridgeStatusForBridgeUpdate(
+ current bridgepkg.BridgeStatus,
+ req bridgepkg.UpdateInstanceRequest,
+) bridgepkg.BridgeStatus {
+ status := current.Normalize()
+ if req.Degradation == nil || req.Degradation.IsZero() {
+ return status
+ }
+ switch status {
+ case bridgepkg.BridgeStatusDegraded,
+ bridgepkg.BridgeStatusAuthRequired,
+ bridgepkg.BridgeStatusError:
+ return status
+ default:
+ return bridgepkg.BridgeStatusDegraded
+ }
+}
+
func (r *bridgeRuntime) DeliveryMetrics() map[string]bridgepkg.BridgeDeliveryMetrics {
if r == nil || r.broker == nil {
return nil
@@ -166,6 +403,96 @@ func (r *bridgeRuntime) DeliveryMetrics() map[string]bridgepkg.BridgeDeliveryMet
return r.broker.DeliveryMetrics()
}
+func (r *bridgeRuntime) BuildBridgeResourceState(
+ ctx context.Context,
+ records []resources.Record[bridgepkg.BridgeInstanceSpec],
+) (resources.ProjectionPlan, error) {
+ if r == nil {
+ return nil, errors.New("daemon: bridge runtime is required")
+ }
+ return bridgepkg.BuildResourceState(ctx, r.store, records, r.now)
+}
+
+func (r *bridgeRuntime) ApplyBridgeResourceState(ctx context.Context, plan resources.ProjectionPlan) error {
+ if r == nil {
+ return errors.New("daemon: bridge runtime is required")
+ }
+ typed, ok := plan.(*bridgepkg.ResourceProjectionPlan)
+ if !ok {
+ return fmt.Errorf("daemon: bridge resource plan has type %T", plan)
+ }
+ if err := bridgepkg.ApplyResourceState(ctx, r.store, typed); err != nil {
+ return err
+ }
+ if typed.OperationCount() == 0 || len(typed.ChangedExtensions()) == 0 {
+ return nil
+ }
+ if err := r.reloadExtensions(ctx, "bridge.resource"); err != nil {
+ rollbackCtx := context.WithoutCancel(ctx)
+ rollbackPlan := typed.RollbackPlan()
+ if rollbackErr := bridgepkg.ApplyResourceState(rollbackCtx, r.store, rollbackPlan); rollbackErr != nil {
+ return fmt.Errorf(
+ "daemon: apply bridge resource state: reload failed and rollback also failed: %w",
+ errors.Join(err, rollbackErr),
+ )
+ }
+ return fmt.Errorf("daemon: apply bridge resource state: rolled back after reload failure: %w", err)
+ }
+ return nil
+}
+
+func (r *bridgeRuntime) applyBridgeResourcesFromStore(ctx context.Context) error {
+ records, err := r.resourceStore.List(ctx, r.resourceActor, resources.ResourceFilter{
+ Kind: bridgepkg.BridgeInstanceResourceKind,
+ })
+ if err != nil {
+ return fmt.Errorf("daemon: list bridge instance resources: %w", err)
+ }
+ plan, err := r.BuildBridgeResourceState(ctx, records)
+ if err != nil {
+ return err
+ }
+ return r.ApplyBridgeResourceState(ctx, plan)
+}
+
+func (r *bridgeRuntime) triggerBridgeResourceReconcile(ctx context.Context) error {
+ if r == nil || r.resourceTrigger == nil {
+ return nil
+ }
+ return r.resourceTrigger(ctx, bridgepkg.BridgeInstanceResourceKind, resources.ReconcileReasonWrite)
+}
+
+func (r *bridgeRuntime) resourceActorForSource(source bridgepkg.BridgeInstanceSource) resources.MutationActor {
+ actor := r.resourceActor
+ if actor.Kind == "" {
+ actor = resourceReconcileActor()
+ }
+ normalized := source.Normalize()
+ if normalized == "" {
+ normalized = bridgepkg.BridgeInstanceSourceDynamic
+ }
+ actor.ID = "bridge." + strings.TrimSpace(string(normalized))
+ actor.Source = resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: actor.ID,
+ }
+ return actor
+}
+
+func (r *bridgeRuntime) resourceActorForRecordSource(source resources.ResourceSource) resources.MutationActor {
+ actor := r.resourceActor
+ if actor.Kind == "" {
+ actor = resourceReconcileActor()
+ }
+ normalized := source.Normalize()
+ if normalized.Kind == "" || strings.TrimSpace(normalized.ID) == "" {
+ return actor
+ }
+ actor.ID = normalized.ID
+ actor.Source = normalized
+ return actor
+}
+
func (r *bridgeRuntime) ListSecretBindings(
ctx context.Context,
bridgeInstanceID string,
@@ -464,6 +791,9 @@ func (r *bridgeRuntime) transitionInstance(
if err := ctx.Err(); err != nil {
return nil, err
}
+ if r.resourceDefinitionsEnabled() {
+ return r.transitionResourceInstance(ctx, id, enabled, status, reload, action)
+ }
trimmedID := strings.TrimSpace(id)
if trimmedID == "" {
@@ -496,6 +826,210 @@ func (r *bridgeRuntime) transitionInstance(
return updated, nil
}
+func (r *bridgeRuntime) transitionResourceInstance(
+ ctx context.Context,
+ id string,
+ enabled bool,
+ status bridgepkg.BridgeStatus,
+ reload bool,
+ action string,
+) (*bridgepkg.BridgeInstance, error) {
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" {
+ return nil, fmt.Errorf("daemon: %s bridge instance id is required", action)
+ }
+
+ currentRecord, err := r.resourceStore.Get(ctx, r.resourceActor, trimmedID)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: %s bridge instance %q: load resource: %w", action, trimmedID, err)
+ }
+
+ ctx, unlockExtension := r.lockExtensionLifecycleContext(ctx, currentRecord.Spec.ExtensionName)
+ defer unlockExtension()
+ ctx, unlockInstance := r.lockInstanceLifecycleContext(ctx, trimmedID)
+ defer unlockInstance()
+
+ previous, err := r.GetInstance(ctx, trimmedID)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: %s bridge instance %q: load current state: %w", action, trimmedID, err)
+ }
+
+ nextSpec := currentRecord.Spec
+ nextSpec.Enabled = enabled
+ updatedRecord, err := r.resourceStore.Put(
+ ctx,
+ r.resourceActorForRecordSource(currentRecord.Source),
+ resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: currentRecord.ID,
+ Scope: bridgepkg.ResourceScopeForBridge(nextSpec.Scope, nextSpec.WorkspaceID),
+ ExpectedVersion: currentRecord.Version,
+ Spec: nextSpec,
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("daemon: %s bridge instance %q: write resource: %w", action, trimmedID, err)
+ }
+ if err := r.applyBridgeResourcesFromStore(ctx); err != nil {
+ return nil, err
+ }
+ return r.finalizeTransitionResourceInstance(
+ ctx,
+ currentRecord,
+ updatedRecord,
+ previous,
+ trimmedID,
+ enabled,
+ status,
+ reload,
+ action,
+ )
+}
+
+func (r *bridgeRuntime) finalizeTransitionResourceInstance(
+ ctx context.Context,
+ currentRecord resources.Record[bridgepkg.BridgeInstanceSpec],
+ updatedRecord resources.Record[bridgepkg.BridgeInstanceSpec],
+ previous *bridgepkg.BridgeInstance,
+ trimmedID string,
+ enabled bool,
+ status bridgepkg.BridgeStatus,
+ reload bool,
+ action string,
+) (*bridgepkg.BridgeInstance, error) {
+ updated, err := r.UpdateInstanceState(ctx, bridgepkg.UpdateInstanceStateRequest{
+ ID: trimmedID,
+ Enabled: enabled,
+ Status: status,
+ UpdatedAt: r.now().UTC(),
+ })
+ if err != nil {
+ return nil, r.rollbackBridgeResourceState(
+ ctx,
+ currentRecord,
+ updatedRecord.Version,
+ previous,
+ action,
+ fmt.Errorf("daemon: %s bridge instance %q: set runtime state: %w", action, trimmedID, err),
+ )
+ }
+
+ if reload {
+ if err := r.reloadExtensions(ctx, trimmedID); err != nil {
+ return nil, r.rollbackBridgeResourceState(
+ ctx,
+ currentRecord,
+ updatedRecord.Version,
+ previous,
+ action,
+ err,
+ )
+ }
+ }
+ if err := r.triggerBridgeResourceReconcile(ctx); err != nil {
+ return nil, r.rollbackBridgeResourceState(
+ ctx,
+ currentRecord,
+ updatedRecord.Version,
+ previous,
+ action,
+ err,
+ )
+ }
+ return updated, nil
+}
+
+func (r *bridgeRuntime) rollbackCreatedBridgeResource(
+ ctx context.Context,
+ createdRecord resources.Record[bridgepkg.BridgeInstanceSpec],
+ action string,
+ cause error,
+) error {
+ rollbackCtx := context.WithoutCancel(ctx)
+ if err := r.resourceStore.Delete(
+ rollbackCtx,
+ r.resourceActorForRecordSource(createdRecord.Source),
+ createdRecord.ID,
+ createdRecord.Version,
+ ); err != nil {
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: follow-up failed and resource rollback also failed: %w",
+ action,
+ createdRecord.ID,
+ errors.Join(cause, err),
+ )
+ }
+ if err := r.applyBridgeResourcesFromStore(rollbackCtx); err != nil {
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: follow-up failed and resource rollback apply also failed: %w",
+ action,
+ createdRecord.ID,
+ errors.Join(cause, err),
+ )
+ }
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: removed created resource after follow-up failure: %w",
+ action,
+ createdRecord.ID,
+ cause,
+ )
+}
+
+func (r *bridgeRuntime) rollbackBridgeResourceState(
+ ctx context.Context,
+ previousRecord resources.Record[bridgepkg.BridgeInstanceSpec],
+ currentVersion int64,
+ previousInstance *bridgepkg.BridgeInstance,
+ action string,
+ cause error,
+) error {
+ rollbackCtx := context.WithoutCancel(ctx)
+ if _, err := r.resourceStore.Put(
+ rollbackCtx,
+ r.resourceActorForRecordSource(previousRecord.Source),
+ resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: previousRecord.ID,
+ Scope: previousRecord.Scope,
+ ExpectedVersion: currentVersion,
+ Spec: previousRecord.Spec,
+ },
+ ); err != nil {
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: follow-up failed and resource rollback also failed: %w",
+ action,
+ previousRecord.ID,
+ errors.Join(cause, err),
+ )
+ }
+ if err := r.applyBridgeResourcesFromStore(rollbackCtx); err != nil {
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: follow-up failed and resource rollback apply also failed: %w",
+ action,
+ previousRecord.ID,
+ errors.Join(cause, err),
+ )
+ }
+ if previousInstance != nil {
+ if err := r.persistCompensatingInstance(
+ rollbackCtx,
+ *previousInstance,
+ "restore bridge instance after resource lifecycle failure",
+ ); err != nil {
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: follow-up failed and state rollback also failed: %w",
+ action,
+ previousRecord.ID,
+ errors.Join(cause, err),
+ )
+ }
+ }
+ return fmt.Errorf(
+ "daemon: %s bridge instance %q: restored persisted state after follow-up failure: %w",
+ action,
+ previousRecord.ID,
+ cause,
+ )
+}
+
func (r *bridgeRuntime) transitionExtensionName(
ctx context.Context,
bridgeInstanceID string,
diff --git a/internal/daemon/bridges_test.go b/internal/daemon/bridges_test.go
index 1e5123604..653bbc237 100644
--- a/internal/daemon/bridges_test.go
+++ b/internal/daemon/bridges_test.go
@@ -15,6 +15,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionpkg "github.com/pedronauck/agh/internal/extension"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/subprocess"
"github.com/pedronauck/agh/internal/testutil"
)
@@ -523,6 +524,53 @@ func TestBridgeRuntimeCreateInstance(t *testing.T) {
})
}
+func TestBridgeRuntimeCreateInstanceResourceBacked(t *testing.T) {
+ t.Run("ShouldRollBackCreatedResourceWhenReconcileFails", func(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 40, 0, 0, time.UTC)
+ triggerErr := errors.New("reconcile boom")
+ runtime, resourceStore := newResourceBackedBridgeRuntime(t, now, func(
+ context.Context,
+ resources.ResourceKind,
+ resources.ReconcileReason,
+ ) error {
+ return triggerErr
+ })
+
+ _, err := runtime.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ ID: "brg-resource-create",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-resource-create",
+ DisplayName: "Resource Create",
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusStarting,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+ if !errors.Is(err, triggerErr) {
+ t.Fatalf("CreateInstance() error = %v, want wrapped reconcile failure", err)
+ }
+
+ if _, getErr := resourceStore.Get(
+ testutil.Context(t),
+ resourceReconcileActor(),
+ "brg-resource-create",
+ ); !errors.Is(
+ getErr,
+ resources.ErrNotFound,
+ ) {
+ t.Fatalf("resourceStore.Get(after failed create) error = %v, want ErrNotFound", getErr)
+ }
+ if _, getErr := runtime.GetInstance(testutil.Context(t), "brg-resource-create"); !errors.Is(
+ getErr,
+ bridgepkg.ErrBridgeInstanceNotFound,
+ ) {
+ t.Fatalf("GetInstance(after failed create) error = %v, want ErrBridgeInstanceNotFound", getErr)
+ }
+ })
+}
+
func TestBridgeRuntimeListProviders(t *testing.T) {
t.Run("ShouldProjectInstalledBridgeProvidersFromExtensionRegistry", func(t *testing.T) {
t.Parallel()
@@ -1213,6 +1261,70 @@ func TestBridgeRuntimeRestartInstance(t *testing.T) {
}
func TestBridgeRuntimeTransition(t *testing.T) {
+ t.Run("ShouldRollBackResourceProjectionWhenReloadFails", func(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil)
+ previous := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{
+ ID: "brg-resource-rollback",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-resource-rollback",
+ DisplayName: "Before",
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+
+ reloadErr := errors.New("reload boom")
+ extensions := &fakeExtensionRuntime{reloadErr: reloadErr}
+ runtime.setExtensionRuntime(extensions)
+ plan, err := runtime.BuildBridgeResourceState(
+ testutil.Context(t),
+ []resources.Record[bridgepkg.BridgeInstanceSpec]{{
+ ID: previous.ID,
+ Version: 2,
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ Spec: bridgepkg.BridgeInstanceSpec{
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-resource-rollback",
+ DisplayName: "After",
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ DMPolicy: bridgepkg.BridgeDMPolicyOpen,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ },
+ CreatedAt: previous.CreatedAt,
+ UpdatedAt: now,
+ }},
+ )
+ if err != nil {
+ t.Fatalf("BuildBridgeResourceState() error = %v", err)
+ }
+
+ err = runtime.ApplyBridgeResourceState(testutil.Context(t), plan)
+ if !errors.Is(err, reloadErr) {
+ t.Fatalf("ApplyBridgeResourceState() error = %v, want reload failure", err)
+ }
+ if got, want := extensions.reloadCount, 1; got != want {
+ t.Fatalf("extension reload count = %d, want %d", got, want)
+ }
+ current, err := runtime.GetInstance(testutil.Context(t), previous.ID)
+ if err != nil {
+ t.Fatalf("GetInstance() error = %v", err)
+ }
+ if got, want := current.DisplayName, "Before"; got != want {
+ t.Fatalf("GetInstance().DisplayName = %q, want %q", got, want)
+ }
+ if got, want := current.Status, bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("GetInstance().Status = %q, want %q", got, want)
+ }
+ })
+
t.Run("ShouldRestorePreviousStateWhenReloadFails", func(t *testing.T) {
t.Parallel()
@@ -1451,6 +1563,122 @@ func TestBridgeRuntimeTransition(t *testing.T) {
})
}
+func TestBridgeRuntimeUpdateInstanceResourceBacked(t *testing.T) {
+ t.Run("ShouldRestorePreviousStateWhenReconcileFails", func(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 50, 0, 0, time.UTC)
+ runtime, resourceStore := newResourceBackedBridgeRuntime(t, now, nil)
+ created := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{
+ ID: "brg-resource-update",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-resource-update",
+ DisplayName: "Before",
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+ if _, err := runtime.UpdateInstanceState(testutil.Context(t), bridgepkg.UpdateInstanceStateRequest{
+ ID: created.ID,
+ Enabled: true,
+ Status: bridgepkg.BridgeStatusReady,
+ UpdatedAt: now.Add(time.Minute),
+ }); err != nil {
+ t.Fatalf("UpdateInstanceState(setup) error = %v", err)
+ }
+
+ triggerErr := errors.New("reconcile boom")
+ runtime.resourceTrigger = func(context.Context, resources.ResourceKind, resources.ReconcileReason) error {
+ return triggerErr
+ }
+ updatedName := "After"
+ degradation := &bridgepkg.BridgeDegradation{
+ Reason: bridgepkg.BridgeDegradationReasonAuthFailed,
+ Message: "token expired",
+ }
+
+ _, err := runtime.UpdateInstance(testutil.Context(t), bridgepkg.UpdateInstanceRequest{
+ ID: created.ID,
+ DisplayName: &updatedName,
+ Degradation: degradation,
+ })
+ if !errors.Is(err, triggerErr) {
+ t.Fatalf("UpdateInstance() error = %v, want wrapped reconcile failure", err)
+ }
+
+ record, getErr := resourceStore.Get(testutil.Context(t), resourceReconcileActor(), created.ID)
+ if getErr != nil {
+ t.Fatalf("resourceStore.Get() error = %v", getErr)
+ }
+ if got, want := record.Spec.DisplayName, "Before"; got != want {
+ t.Fatalf("resourceStore.Get().Spec.DisplayName = %q, want %q", got, want)
+ }
+
+ current, getErr := runtime.GetInstance(testutil.Context(t), created.ID)
+ if getErr != nil {
+ t.Fatalf("GetInstance() error = %v", getErr)
+ }
+ if got, want := current.DisplayName, "Before"; got != want {
+ t.Fatalf("GetInstance().DisplayName = %q, want %q", got, want)
+ }
+ if got, want := current.Status, bridgepkg.BridgeStatusReady; got != want {
+ t.Fatalf("GetInstance().Status = %q, want %q", got, want)
+ }
+ if current.Degradation != nil {
+ t.Fatalf("GetInstance().Degradation = %#v, want nil", current.Degradation)
+ }
+ })
+}
+
+func TestBridgeRuntimeTransitionResourceBacked(t *testing.T) {
+ t.Run("ShouldRestorePreviousStateWhenReconcileFails", func(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC)
+ runtime, resourceStore := newResourceBackedBridgeRuntime(t, now, nil)
+ created := mustCreateDaemonBridgeInstance(t, runtime, bridgepkg.CreateInstanceRequest{
+ ID: "brg-resource-transition",
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-resource-transition",
+ DisplayName: "Resource Transition",
+ Enabled: false,
+ Status: bridgepkg.BridgeStatusDisabled,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+
+ triggerErr := errors.New("reconcile boom")
+ runtime.resourceTrigger = func(context.Context, resources.ResourceKind, resources.ReconcileReason) error {
+ return triggerErr
+ }
+
+ _, err := runtime.StartInstance(testutil.Context(t), created.ID)
+ if !errors.Is(err, triggerErr) {
+ t.Fatalf("StartInstance() error = %v, want wrapped reconcile failure", err)
+ }
+
+ record, getErr := resourceStore.Get(testutil.Context(t), resourceReconcileActor(), created.ID)
+ if getErr != nil {
+ t.Fatalf("resourceStore.Get() error = %v", getErr)
+ }
+ if record.Spec.Enabled {
+ t.Fatal("resourceStore.Get().Spec.Enabled = true, want disabled rollback")
+ }
+
+ current, getErr := runtime.GetInstance(testutil.Context(t), created.ID)
+ if getErr != nil {
+ t.Fatalf("GetInstance() error = %v", getErr)
+ }
+ if current.Enabled {
+ t.Fatal("GetInstance().Enabled = true, want disabled rollback")
+ }
+ if got, want := current.Status, bridgepkg.BridgeStatusDisabled; got != want {
+ t.Fatalf("GetInstance().Status = %q, want %q", got, want)
+ }
+ })
+}
+
func mustCreateDaemonBridgeInstance(
t *testing.T,
runtime *bridgeRuntime,
@@ -1465,6 +1693,35 @@ func mustCreateDaemonBridgeInstance(
return instance
}
+func newResourceBackedBridgeRuntime(
+ t *testing.T,
+ now time.Time,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+) (*bridgeRuntime, resources.Store[bridgepkg.BridgeInstanceSpec]) {
+ t.Helper()
+
+ db := openDaemonTestGlobalDB(t)
+ runtime := newBridgeRuntime(db, discardLogger(), func() time.Time { return now }, nil)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codecs := resources.NewCodecRegistry()
+ codec, err := bridgepkg.NewBridgeInstanceResourceCodec(nil)
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(codecs, codec); err != nil {
+ t.Fatalf("RegisterCodec() error = %v", err)
+ }
+ resourceStore, err := bridgeInstanceResourceStore(kernel, codecs)
+ if err != nil {
+ t.Fatalf("bridgeInstanceResourceStore() error = %v", err)
+ }
+ runtime.setResourceDefinitions(resourceStore, resourceReconcileActor(), trigger)
+ return runtime, resourceStore
+}
+
func mustUpsertDaemonBridgeRoute(
t *testing.T,
runtime *bridgeRuntime,
diff --git a/internal/daemon/bundle_resources.go b/internal/daemon/bundle_resources.go
new file mode 100644
index 000000000..6b3cd59e0
--- /dev/null
+++ b/internal/daemon/bundle_resources.go
@@ -0,0 +1,538 @@
+package daemon
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "time"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ bundlepkg "github.com/pedronauck/agh/internal/bundles"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const bundleManagedIDPrefix = "daemon.sync.bundle."
+
+type bundleResourcePublisher interface {
+ Sync(context.Context) error
+}
+
+type bundleResourcePublisherFunc func(context.Context) error
+
+func (f bundleResourcePublisherFunc) Sync(ctx context.Context) error {
+ if f == nil {
+ return nil
+ }
+ return f(ctx)
+}
+
+type bundleNoopProjectionPlan struct {
+ revision int64
+ operations int
+}
+
+func (p *bundleNoopProjectionPlan) Kind() resources.ResourceKind {
+ return bundlepkg.BundleResourceKind
+}
+
+func (p *bundleNoopProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *bundleNoopProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+type bundleNoopProjector struct{}
+
+var _ resources.TypedProjector[bundlepkg.BundleResourceSpec] = (*bundleNoopProjector)(nil)
+
+func (p *bundleNoopProjector) Kind() resources.ResourceKind {
+ return bundlepkg.BundleResourceKind
+}
+
+func (p *bundleNoopProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *bundleNoopProjector) Build(
+ _ context.Context,
+ records []resources.Record[bundlepkg.BundleResourceSpec],
+) (resources.ProjectionPlan, error) {
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ }
+ return &bundleNoopProjectionPlan{revision: revision, operations: len(records)}, nil
+}
+
+func (p *bundleNoopProjector) Apply(context.Context, resources.ProjectionPlan) error {
+ return nil
+}
+
+type bundlePublicationInput struct {
+ sourceKey string
+ scope resources.ResourceScope
+ spec bundlepkg.BundleResourceSpec
+}
+
+type bundleDeclarationProvider func(context.Context) ([]bundlePublicationInput, error)
+
+type bundleSourceSyncer struct {
+ store resources.Store[bundlepkg.BundleResourceSpec]
+ codec resources.KindCodec[bundlepkg.BundleResourceSpec]
+ actor resources.MutationActor
+ logger *slog.Logger
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ providers []bundleDeclarationProvider
+}
+
+func newBundleSourceSyncer(
+ store resources.Store[bundlepkg.BundleResourceSpec],
+ codec resources.KindCodec[bundlepkg.BundleResourceSpec],
+ actor resources.MutationActor,
+ logger *slog.Logger,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+ providers ...bundleDeclarationProvider,
+) bundleResourcePublisher {
+ if store == nil || codec == nil {
+ return nil
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &bundleSourceSyncer{
+ store: store,
+ codec: codec,
+ actor: actor,
+ logger: logger,
+ trigger: trigger,
+ providers: append([]bundleDeclarationProvider(nil), providers...),
+ }
+}
+
+func bundleSyncActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "bundle-sync",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "bundle-sync",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *bundleSourceSyncer) Sync(ctx context.Context) error {
+ if s == nil {
+ return nil
+ }
+ if ctx == nil {
+ return errors.New("daemon: bundle sync context is required")
+ }
+
+ desired, err := s.desiredBundles(ctx)
+ if err != nil {
+ return err
+ }
+ changed, err := s.syncBundles(ctx, desired)
+ if err != nil {
+ return err
+ }
+ if changed && s.trigger != nil {
+ return s.trigger(ctx, bundlepkg.BundleResourceKind, resources.ReconcileReasonWrite)
+ }
+ return nil
+}
+
+type desiredBundleResource struct {
+ id string
+ scope resources.ResourceScope
+ spec bundlepkg.BundleResourceSpec
+ encoded []byte
+}
+
+func (s *bundleSourceSyncer) desiredBundles(
+ ctx context.Context,
+) (map[string]desiredBundleResource, error) {
+ desired := make(map[string]desiredBundleResource)
+ for _, provider := range s.providers {
+ if provider == nil {
+ continue
+ }
+ items, err := provider(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range items {
+ spec, encoded, err := validateAndEncodeBundle(ctx, s.codec, item.scope, item.spec)
+ if err != nil {
+ return nil, err
+ }
+ id := bundlepkg.BundleResourceID(spec.ExtensionName, spec.Bundle.Name)
+ if id == "" {
+ id = managedResourceID(bundleManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ }
+ desired[id] = desiredBundleResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ }
+ return desired, nil
+}
+
+func (s *bundleSourceSyncer) syncBundles(
+ ctx context.Context,
+ desired map[string]desiredBundleResource,
+) (bool, error) {
+ source := s.actor.Source
+ current, err := s.store.List(ctx, s.actor, resources.ResourceFilter{
+ Kind: bundlepkg.BundleResourceKind,
+ Source: &source,
+ })
+ if err != nil {
+ return false, fmt.Errorf("daemon: list managed bundles: %w", err)
+ }
+ currentByID := make(map[string]resources.Record[bundlepkg.BundleResourceSpec], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredBundle := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameBundle(existing, desiredBundle.scope, desiredBundle.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.store.Put(ctx, s.actor, resources.Draft[bundlepkg.BundleResourceSpec]{
+ ID: desiredBundle.id,
+ Scope: desiredBundle.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredBundle.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync bundle %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+ for _, stale := range currentByID {
+ if err := s.store.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale bundle %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+ return changed, nil
+}
+
+func (s *bundleSourceSyncer) sameBundle(
+ record resources.Record[bundlepkg.BundleResourceSpec],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+ currentEncoded, err := s.codec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func extensionManifestBundleDeclarationProvider(
+ registry *extensionpkg.Registry,
+ runtime func() extensionRuntime,
+ logger *slog.Logger,
+) bundleDeclarationProvider {
+ return func(_ context.Context) ([]bundlePublicationInput, error) {
+ if registry == nil || runtime == nil {
+ return nil, nil
+ }
+ manager := runtime()
+ if manager == nil {
+ return nil, nil
+ }
+ infos, err := registry.List()
+ if err != nil {
+ return nil, fmt.Errorf("daemon: list extensions for bundle sync: %w", err)
+ }
+ slices.SortFunc(infos, func(left, right extensionpkg.ExtensionInfo) int {
+ return strings.Compare(left.Name, right.Name)
+ })
+
+ globalScope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ var desired []bundlePublicationInput
+ for _, info := range infos {
+ if !info.Enabled {
+ continue
+ }
+ ext, err := loadExtensionSnapshot(registry, manager, logger, info.Name)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: load extension %q for bundle sync: %w", info.Name, err)
+ }
+ if ext == nil || ext.Manifest == nil || !ext.Status.Registered {
+ continue
+ }
+ for _, bundle := range ext.Bundles {
+ desired = append(desired, bundlePublicationInput{
+ sourceKey: "extension/" + ext.Info.Name + "/bundle/" + strings.TrimSpace(bundle.Name),
+ scope: globalScope,
+ spec: bundlepkg.BundleResourceSpec{
+ ExtensionName: strings.TrimSpace(ext.Info.Name),
+ Bundle: bundle,
+ OwnerBridgePlatform: strings.TrimSpace(ext.Manifest.Bridge.Platform),
+ OwnerProvidesBridgeAdapter: slices.Contains(
+ ext.Manifest.Capabilities.Provides,
+ "bridge.adapter",
+ ),
+ },
+ })
+ }
+ }
+ return desired, nil
+ }
+}
+
+func validateAndEncodeBundle(
+ ctx context.Context,
+ codec resources.KindCodec[bundlepkg.BundleResourceSpec],
+ scope resources.ResourceScope,
+ spec bundlepkg.BundleResourceSpec,
+) (bundlepkg.BundleResourceSpec, []byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return bundlepkg.BundleResourceSpec{}, nil, err
+ }
+ validated, err := codec.DecodeAndValidate(ctx, scope.Normalize(), encoded)
+ if err != nil {
+ return bundlepkg.BundleResourceSpec{}, nil, err
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return bundlepkg.BundleResourceSpec{}, nil, err
+ }
+ return validated, canonical, nil
+}
+
+func appendBundleProjectorRegistrations(
+ registrations []resources.ProjectorRegistration,
+ deps *resourceReconcileDriverDeps,
+) ([]resources.ProjectorRegistration, error) {
+ bundleCodec, err := resources.ResolveCodec[bundlepkg.BundleResourceSpec](
+ deps.CodecRegistry,
+ bundlepkg.BundleResourceKind,
+ )
+ if err != nil {
+ return nil, err
+ }
+ bundleRegistration, err := resources.NewTypedProjectorRegistration(
+ bundleCodec,
+ &bundleNoopProjector{},
+ )
+ if err != nil {
+ return nil, err
+ }
+ activationRegistration, err := resources.NewBundleActivationProjectorRegistration[
+ bundlepkg.ActivationResourceSpec,
+ bundlepkg.BundleResourceSpec,
+ ](deps.CodecRegistry, deps.Bundles)
+ if err != nil {
+ return nil, err
+ }
+ return append(registrations, bundleRegistration, activationRegistration), nil
+}
+
+func newBundleResourcePublisher(
+ state *bootState,
+ registry *extensionpkg.Registry,
+) (bundleResourcePublisher, error) {
+ publisher := bundleResourcePublisher(bundleResourcePublisherFunc(func(context.Context) error { return nil }))
+ if state == nil || state.resourceKernel == nil || state.resourceCodecs == nil {
+ return publisher, nil
+ }
+ codec, err := resources.ResolveCodec[bundlepkg.BundleResourceSpec](
+ state.resourceCodecs,
+ bundlepkg.BundleResourceKind,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve bundle codec: %w", err)
+ }
+ store, err := resources.NewStore(state.resourceKernel, codec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create bundle store: %w", err)
+ }
+ return newBundleSourceSyncer(
+ store,
+ codec,
+ bundleSyncActor(),
+ state.logger,
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ extensionManifestBundleDeclarationProvider(registry, state.currentExtensionRuntime, state.logger),
+ ), nil
+}
+
+func (d *Daemon) newBundlePublisher(
+ state *bootState,
+ registry *extensionpkg.Registry,
+) (bundleResourcePublisher, error) {
+ return newBundleResourcePublisher(state, registry)
+}
+
+func newBundleResourceStore(
+ state *bootState,
+ now func() time.Time,
+) (*bundlepkg.ResourceStore, error) {
+ if state == nil || state.resourceKernel == nil || state.resourceCodecs == nil {
+ return nil, nil
+ }
+ raw := resourceRawStore(state.resourceKernel)
+ if raw == nil {
+ return nil, nil
+ }
+
+ deps, err := resolveBundleResourceStoreDeps(state, raw)
+ if err != nil {
+ return nil, err
+ }
+
+ return bundlepkg.NewResourceStore(bundlepkg.ResourceStoreConfig{
+ Bundles: deps.bundleStore,
+ BundleCodec: deps.bundleCodec,
+ Activations: deps.activationStore,
+ ActivationCodec: deps.activationCodec,
+ Jobs: deps.jobStore,
+ JobCodec: deps.jobCodec,
+ Triggers: deps.triggerStore,
+ TriggerCodec: deps.triggerCodec,
+ Bridges: deps.bridgeStore,
+ BridgeCodec: deps.bridgeCodec,
+ Actor: resourceReconcileActor(),
+ Trigger: func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ Now: now,
+ })
+}
+
+type bundleResourceStoreDeps struct {
+ bundleCodec resources.KindCodec[bundlepkg.BundleResourceSpec]
+ bundleStore resources.Store[bundlepkg.BundleResourceSpec]
+ activationCodec resources.KindCodec[bundlepkg.ActivationResourceSpec]
+ activationStore resources.Store[bundlepkg.ActivationResourceSpec]
+ jobCodec resources.KindCodec[automationpkg.Job]
+ jobStore resources.Store[automationpkg.Job]
+ triggerCodec resources.KindCodec[automationpkg.Trigger]
+ triggerStore resources.Store[automationpkg.Trigger]
+ bridgeCodec resources.KindCodec[bridgepkg.BridgeInstanceSpec]
+ bridgeStore resources.Store[bridgepkg.BridgeInstanceSpec]
+}
+
+func resolveBundleResourceStoreDeps(
+ state *bootState,
+ raw resources.RawStore,
+) (bundleResourceStoreDeps, error) {
+ bundleCodec, bundleStore, err := resolveDaemonResourceStore[bundlepkg.BundleResourceSpec](
+ state,
+ raw,
+ bundlepkg.BundleResourceKind,
+ "bundle",
+ )
+ if err != nil {
+ return bundleResourceStoreDeps{}, err
+ }
+ activationCodec, activationStore, err := resolveDaemonResourceStore[bundlepkg.ActivationResourceSpec](
+ state,
+ raw,
+ bundlepkg.BundleActivationResourceKind,
+ "bundle activation",
+ )
+ if err != nil {
+ return bundleResourceStoreDeps{}, err
+ }
+ jobCodec, jobStore, err := resolveDaemonResourceStore[automationpkg.Job](
+ state,
+ raw,
+ automationpkg.JobResourceKind,
+ "bundle automation job",
+ )
+ if err != nil {
+ return bundleResourceStoreDeps{}, err
+ }
+ triggerCodec, triggerStore, err := resolveDaemonResourceStore[automationpkg.Trigger](
+ state,
+ raw,
+ automationpkg.TriggerResourceKind,
+ "bundle automation trigger",
+ )
+ if err != nil {
+ return bundleResourceStoreDeps{}, err
+ }
+ bridgeCodec, bridgeStore, err := resolveDaemonResourceStore[bridgepkg.BridgeInstanceSpec](
+ state,
+ raw,
+ bridgepkg.BridgeInstanceResourceKind,
+ "bundle bridge instance",
+ )
+ if err != nil {
+ return bundleResourceStoreDeps{}, err
+ }
+ return bundleResourceStoreDeps{
+ bundleCodec: bundleCodec,
+ bundleStore: bundleStore,
+ activationCodec: activationCodec,
+ activationStore: activationStore,
+ jobCodec: jobCodec,
+ jobStore: jobStore,
+ triggerCodec: triggerCodec,
+ triggerStore: triggerStore,
+ bridgeCodec: bridgeCodec,
+ bridgeStore: bridgeStore,
+ }, nil
+}
+
+func resolveDaemonResourceStore[T any](
+ state *bootState,
+ raw resources.RawStore,
+ kind resources.ResourceKind,
+ label string,
+) (resources.KindCodec[T], resources.Store[T], error) {
+ codec, err := resources.ResolveCodec[T](state.resourceCodecs, kind)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: resolve %s codec: %w", label, err)
+ }
+ store, err := resources.NewStore(raw, codec)
+ if err != nil {
+ return nil, nil, fmt.Errorf("daemon: create %s resource store: %w", label, err)
+ }
+ return codec, store, nil
+}
diff --git a/internal/daemon/bundle_resources_test.go b/internal/daemon/bundle_resources_test.go
new file mode 100644
index 000000000..2b8788f60
--- /dev/null
+++ b/internal/daemon/bundle_resources_test.go
@@ -0,0 +1,94 @@
+package daemon
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ bundlepkg "github.com/pedronauck/agh/internal/bundles"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+type fakeBundleActivationProjector struct{}
+
+func (fakeBundleActivationProjector) Build(
+ context.Context,
+ []resources.Record[bundlepkg.ActivationResourceSpec],
+ []resources.Record[bundlepkg.BundleResourceSpec],
+) (resources.ProjectionPlan, error) {
+ return fakeBundleActivationPlan{}, nil
+}
+
+func (fakeBundleActivationProjector) Apply(context.Context, resources.ProjectionPlan) error {
+ return nil
+}
+
+type fakeBundleActivationPlan struct{}
+
+func (fakeBundleActivationPlan) Kind() resources.ResourceKind {
+ return bundlepkg.BundleActivationResourceKind
+}
+
+func (fakeBundleActivationPlan) Revision() int64 {
+ return 0
+}
+
+func (fakeBundleActivationPlan) OperationCount() int {
+ return 0
+}
+
+func TestBundleProjectorRegistrationsKeepActivationCycleFree(t *testing.T) {
+ t.Parallel()
+
+ registry := resources.NewCodecRegistry()
+ bundleCodec, err := bundlepkg.NewBundleResourceCodec()
+ if err != nil {
+ t.Fatalf("NewBundleResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(registry, bundleCodec); err != nil {
+ t.Fatalf("RegisterCodec(bundle) error = %v", err)
+ }
+ activationCodec, err := bundlepkg.NewActivationResourceCodec()
+ if err != nil {
+ t.Fatalf("NewActivationResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(registry, activationCodec); err != nil {
+ t.Fatalf("RegisterCodec(activation) error = %v", err)
+ }
+
+ registrations, err := appendBundleProjectorRegistrations(nil, &resourceReconcileDriverDeps{
+ CodecRegistry: registry,
+ Bundles: fakeBundleActivationProjector{},
+ })
+ if err != nil {
+ t.Fatalf("appendBundleProjectorRegistrations() error = %v", err)
+ }
+ byKind := make(map[resources.ResourceKind]resources.ProjectorRegistration, len(registrations))
+ for _, registration := range registrations {
+ byKind[registration.Kind()] = registration
+ }
+
+ bundleRegistration, ok := byKind[bundlepkg.BundleResourceKind]
+ if !ok {
+ t.Fatal("bundle projector registration missing")
+ }
+ if deps := bundleRegistration.DependsOn(); len(deps) != 0 {
+ t.Fatalf("bundle DependsOn() = %#v, want none", deps)
+ }
+
+ activationRegistration, ok := byKind[bundlepkg.BundleActivationResourceKind]
+ if !ok {
+ t.Fatal("bundle activation projector registration missing")
+ }
+ deps := activationRegistration.DependsOn()
+ if !slices.Equal(deps, []resources.ResourceKind{bundlepkg.BundleResourceKind}) {
+ t.Fatalf("bundle.activation DependsOn() = %#v, want [bundle]", deps)
+ }
+ if slices.Contains(deps, automationpkg.JobResourceKind) ||
+ slices.Contains(deps, automationpkg.TriggerResourceKind) ||
+ slices.Contains(deps, bridgepkg.BridgeInstanceResourceKind) {
+ t.Fatalf("bundle.activation DependsOn() includes downstream fan-out kinds: %#v", deps)
+ }
+}
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 8b8ea9f21..7788cd698 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -18,18 +18,22 @@ import (
"github.com/pedronauck/agh/internal/api/udsapi"
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ bundlepkg "github.com/pedronauck/agh/internal/bundles"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
extensionpkg "github.com/pedronauck/agh/internal/extension"
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/memory/consolidation"
"github.com/pedronauck/agh/internal/observe"
"github.com/pedronauck/agh/internal/procutil"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/store/globaldb"
taskpkg "github.com/pedronauck/agh/internal/task"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -48,6 +52,33 @@ type ConfigLoader func() (aghconfig.Config, error)
// SessionManager is the shared transport-facing session surface consumed by daemon/.
type SessionManager = core.SessionManager
+type environmentExecSessionManager interface {
+ ExecEnvironment(context.Context, session.EnvironmentExecRequest) (session.EnvironmentExecResult, error)
+}
+
+type hostAPISessionManagerAdapter struct {
+ core.SessionManager
+ exec environmentExecSessionManager
+}
+
+func newHostAPISessionManagerAdapter(sessions SessionManager) hostAPISessionManagerAdapter {
+ adapter := hostAPISessionManagerAdapter{SessionManager: sessions}
+ if exec, ok := sessions.(environmentExecSessionManager); ok {
+ adapter.exec = exec
+ }
+ return adapter
+}
+
+func (a hostAPISessionManagerAdapter) ExecEnvironment(
+ ctx context.Context,
+ req session.EnvironmentExecRequest,
+) (session.EnvironmentExecResult, error) {
+ if a.exec == nil {
+ return session.EnvironmentExecResult{}, session.ErrSessionNotActive
+ }
+ return a.exec.ExecEnvironment(ctx, req)
+}
+
// Observer is the daemon observer surface used for transport wiring and reconciliation.
type Observer interface {
core.Observer
@@ -84,10 +115,12 @@ type RuntimeDeps struct {
MemoryStore *memory.Store
WorkspaceResolver workspacepkg.RuntimeResolver
WorkspaceService core.WorkspaceService
+ AgentCatalog core.AgentCatalog
SkillsRegistry core.SkillsRegistry
DreamTrigger DreamTrigger
Extensions udsapi.ExtensionService
Bundles core.BundleService
+ Resources core.ResourceService
StartedAt time.Time
}
@@ -102,6 +135,10 @@ type sessionManagerFactory func(ctx context.Context, deps SessionManagerDeps) (S
type observerFactory func(ctx context.Context, deps RuntimeDeps) (Observer, error)
type extensionManagerFactory func(deps extensionManagerDeps) extensionRuntime
type automationManagerFactory func(deps automationManagerDeps) (automationRuntime, error)
+type resourceReconcileDriverFactory func(
+ ctx context.Context,
+ deps resourceReconcileDriverDeps,
+) (resources.ReconcileDriver, error)
type networkRuntime interface {
core.NetworkService
@@ -129,6 +166,22 @@ type extensionDBSource interface {
DB() *sql.DB
}
+type resourceReconcileDriverDeps struct {
+ Config aghconfig.Config
+ Logger *slog.Logger
+ Registry Registry
+ ResourceStore resources.RawStore
+ CodecRegistry *resources.CodecRegistry
+ Hooks *hookspkg.Hooks
+ AgentCatalog *resourceCatalog[aghconfig.AgentDef]
+ ToolCatalog *resourceCatalog[toolspkg.Tool]
+ MCPServerCatalog *resourceCatalog[aghconfig.MCPServer]
+ SkillsRegistry *skills.Registry
+ Automation automationResourceProjectorTarget
+ Bridges bridgeResourceProjectorTarget
+ Bundles resources.BundleActivationProjector[bundlepkg.ActivationResourceSpec, bundlepkg.BundleResourceSpec]
+}
+
type extensionRuntime interface {
Start(context.Context) error
Stop(context.Context) error
@@ -150,6 +203,7 @@ func bridgeObserveSource(service core.BridgeService) observe.BridgeSource {
type extensionManagerDeps struct {
Registry *extensionpkg.Registry
+ Extensions aghconfig.ExtensionsConfig
Sessions SessionManager
Automation func() extensionpkg.HostAPIAutomationManager
Tasks taskpkg.Manager
@@ -162,12 +216,15 @@ type extensionManagerDeps struct {
BridgeDedupStore bridgeDedupStore
BridgeBroker *bridgepkg.Broker
BridgeRuntime extensionpkg.BridgeRuntimeResolver
+ ResourceStore resources.RawStore
+ SourceSessions resources.SourceSessionManager
+ ResourceCodecs *resources.CodecRegistry
+ ResourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
}
type automationRuntime interface {
core.AutomationManager
extensionpkg.HostAPIAutomationManager
- bundleSyncAutomation
Start(ctx context.Context) error
Shutdown(ctx context.Context) error
SessionObserver() session.Notifier
@@ -175,16 +232,6 @@ type automationRuntime interface {
MemoryObserver() automationpkg.MemoryConsolidationObserver
}
-type bundleSyncAutomation interface {
- SyncManagedDefinitions(
- ctx context.Context,
- source automationpkg.JobSource,
- desiredJobs []automationpkg.Job,
- desiredTriggers []automationpkg.Trigger,
- desiredTriggerSecrets map[string]string,
- ) (automationpkg.SyncStats, error)
-}
-
type automationManagerDeps struct {
Store automationpkg.Store
Sessions SessionManager
@@ -194,18 +241,23 @@ type automationManagerDeps struct {
Hooks automationpkg.HookDispatcher
Logger *slog.Logger
GlobalWorkspacePath string
+ ResourceStore resources.RawStore
+ ResourceCodecs *resources.CodecRegistry
+ ResourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
}
// SessionManagerDeps captures the composition-root dependencies needed to create a session manager.
type SessionManagerDeps struct {
- HomePaths aghconfig.HomePaths
- Logger *slog.Logger
- Notifier session.Notifier
- Hooks session.HookSet
- PromptAssembler session.PromptAssembler
- SkillRegistry session.SkillRegistry
- MCPResolver session.MCPResolver
- WorkspaceResolver workspacepkg.RuntimeResolver
+ HomePaths aghconfig.HomePaths
+ Logger *slog.Logger
+ Notifier session.Notifier
+ Hooks session.HookSet
+ PromptAssembler session.PromptAssembler
+ AgentResolver session.AgentResolver
+ SkillRegistry session.SkillRegistry
+ MCPResolver session.MCPResolver
+ WorkspaceResolver workspacepkg.RuntimeResolver
+ EnvironmentRegistry *environment.Registry
}
// Daemon is the sole AGH composition root.
@@ -225,6 +277,7 @@ type Daemon struct {
newObserver observerFactory
newExtensionManager extensionManagerFactory
newAutomationManager automationManagerFactory
+ newResourceReconcile resourceReconcileDriverFactory
httpFactory ServerFactory
udsFactory ServerFactory
listProcesses func(context.Context) ([]processInfo, error)
@@ -252,33 +305,39 @@ type Daemon struct {
hooks hookRuntime
extensions extensionRuntime
observer Observer
+ resourceReconcile resources.ReconcileDriver
+ agentCatalog *resourceCatalog[aghconfig.AgentDef]
+ toolCatalog *resourceCatalog[toolspkg.Tool]
+ mcpServerCatalog *resourceCatalog[aghconfig.MCPServer]
automation automationRuntime
bridges *bridgeRuntime
httpServer Server
udsServer Server
dreamRuntime *consolidation.Runtime
workspaceResolver workspacepkg.RuntimeResolver
+ environmentRegistry *environment.Registry
skillsRegistry *skills.Registry
skillsCancel context.CancelFunc
skillsDone chan struct{}
}
type shutdownTargets struct {
- sessions SessionManager
- network networkRuntime
- hooks hookRuntime
- extensions extensionRuntime
- automation automationRuntime
- bridges *bridgeRuntime
- httpServer Server
- udsServer Server
- registry Registry
- lock *Lock
- closeLogger func() error
- infoPath string
- dreamRuntime *consolidation.Runtime
- skillsCancel context.CancelFunc
- skillsDone chan struct{}
+ sessions SessionManager
+ network networkRuntime
+ hooks hookRuntime
+ extensions extensionRuntime
+ automation automationRuntime
+ resourceReconcile resources.ReconcileDriver
+ bridges *bridgeRuntime
+ httpServer Server
+ udsServer Server
+ registry Registry
+ lock *Lock
+ closeLogger func() error
+ infoPath string
+ dreamRuntime *consolidation.Runtime
+ skillsCancel context.CancelFunc
+ skillsDone chan struct{}
}
// WithHomePaths overrides the resolved AGH home layout.
@@ -420,6 +479,7 @@ func (d *Daemon) applyRuntimeFactoryDefaults() {
d.applyObserverFactoryDefault()
d.applyExtensionManagerFactoryDefault()
d.applyAutomationManagerFactoryDefault()
+ d.applyResourceReconcileDriverFactoryDefault()
}
func (d *Daemon) applySessionManagerFactoryDefault() {
@@ -434,9 +494,11 @@ func (d *Daemon) applySessionManagerFactoryDefault() {
session.WithNotifier(deps.Notifier),
session.WithHookSet(deps.Hooks),
session.WithPromptAssembler(deps.PromptAssembler),
+ session.WithAgentResolver(deps.AgentResolver),
session.WithSkillRegistry(deps.SkillRegistry),
session.WithMCPResolver(deps.MCPResolver),
session.WithWorkspaceResolver(deps.WorkspaceResolver),
+ session.WithEnvironmentRegistry(deps.EnvironmentRegistry),
)
}
}
@@ -468,32 +530,40 @@ func (d *Daemon) applyExtensionManagerFactoryDefault() {
return
}
d.newExtensionManager = func(deps extensionManagerDeps) extensionRuntime {
- if deps.Registry == nil {
+ if deps.Registry == nil || deps.ResourceStore == nil || deps.SourceSessions == nil {
return nil
}
capChecker := &extensionpkg.CapabilityChecker{}
+ capChecker.SetResourcePolicy(deps.Extensions.Resources)
hostAPI := extensionpkg.NewHostAPIHandler(
- deps.Sessions,
+ newHostAPISessionManagerAdapter(deps.Sessions),
deps.MemoryStore,
deps.Observer,
deps.SkillsRegistry,
- buildHostAPIOptions(deps, capChecker)...,
+ buildHostAPIOptions(deps, capChecker, deps.ResourceStore)...,
)
- return extensionpkg.NewManager(deps.Registry, buildExtensionManagerOptions(deps, capChecker, hostAPI)...)
+ return extensionpkg.NewManager(
+ deps.Registry,
+ buildExtensionManagerOptions(deps, capChecker, hostAPI, deps.SourceSessions)...,
+ )
}
}
func buildHostAPIOptions(
deps extensionManagerDeps,
capChecker *extensionpkg.CapabilityChecker,
+ resourceStore resources.RawStore,
) []extensionpkg.HostAPIOption {
opts := []extensionpkg.HostAPIOption{
extensionpkg.WithHostAPIAutomationGetter(deps.Automation),
extensionpkg.WithHostAPITaskManager(deps.Tasks),
extensionpkg.WithHostAPICapabilityChecker(capChecker),
extensionpkg.WithHostAPIWorkspaceResolver(deps.WorkspaceResolver),
+ extensionpkg.WithHostAPIResourceStore(resourceStore),
+ extensionpkg.WithHostAPIResourceCodecRegistry(deps.ResourceCodecs),
+ extensionpkg.WithHostAPIResourceTrigger(deps.ResourceTrigger),
}
if deps.BridgeRegistry != nil {
opts = append(opts, extensionpkg.WithHostAPIBridgeRegistry(deps.BridgeRegistry))
@@ -511,11 +581,12 @@ func buildExtensionManagerOptions(
deps extensionManagerDeps,
capChecker *extensionpkg.CapabilityChecker,
hostAPI *extensionpkg.HostAPIHandler,
+ sourceSessions resources.SourceSessionManager,
) []extensionpkg.Option {
opts := []extensionpkg.Option{
extensionpkg.WithCapabilityChecker(capChecker),
- extensionpkg.WithSkillsRegistry(deps.SkillsRegistry),
extensionpkg.WithLogger(deps.Logger),
+ extensionpkg.WithSourceSessionManager(sourceSessions),
}
if sink, ok := deps.Observer.(extensionpkg.BridgeTelemetrySink); ok {
opts = append(opts, extensionpkg.WithBridgeTelemetrySink(sink))
@@ -534,7 +605,21 @@ func (d *Daemon) applyAutomationManagerFactoryDefault() {
return
}
d.newAutomationManager = func(deps automationManagerDeps) (automationRuntime, error) {
- manager, err := automationpkg.New(
+ jobStore, triggerStore, err := automationResourceStores(deps.ResourceStore, deps.ResourceCodecs)
+ if err != nil {
+ return nil, err
+ }
+ resourceOpts := []automationpkg.Option(nil)
+ if jobStore != nil && triggerStore != nil {
+ resourceOpts = append(resourceOpts, automationpkg.WithResourceDefinitions(
+ jobStore,
+ triggerStore,
+ resourceReconcileActor(),
+ deps.ResourceTrigger,
+ ))
+ }
+
+ managerOpts := []automationpkg.Option{
automationpkg.WithStore(deps.Store),
automationpkg.WithSessions(deps.Sessions),
automationpkg.WithTasks(deps.Tasks),
@@ -543,7 +628,10 @@ func (d *Daemon) applyAutomationManagerFactoryDefault() {
automationpkg.WithHooks(deps.Hooks),
automationpkg.WithLogger(deps.Logger),
automationpkg.WithGlobalWorkspacePath(deps.GlobalWorkspacePath),
- )
+ }
+ managerOpts = append(managerOpts, resourceOpts...)
+
+ manager, err := automationpkg.New(managerOpts...)
if err != nil {
return nil, err
}
@@ -551,6 +639,179 @@ func (d *Daemon) applyAutomationManagerFactoryDefault() {
}
}
+func (d *Daemon) applyResourceReconcileDriverFactoryDefault() {
+ if d.newResourceReconcile != nil {
+ return
+ }
+ d.newResourceReconcile = func(
+ _ context.Context,
+ deps resourceReconcileDriverDeps,
+ ) (resources.ReconcileDriver, error) {
+ if deps.ResourceStore == nil || deps.CodecRegistry == nil {
+ return resources.NewReconcileDriver(
+ nil,
+ resources.MutationActor{},
+ nil,
+ resources.WithReconcileLogger(deps.Logger),
+ )
+ }
+
+ registrations, err := buildResourceProjectorRegistrations(&deps)
+ if err != nil {
+ return nil, err
+ }
+
+ return resources.NewReconcileDriver(
+ deps.ResourceStore,
+ resourceReconcileActor(),
+ registrations,
+ resources.WithReconcileLogger(deps.Logger),
+ )
+ }
+}
+
+func buildResourceProjectorRegistrations(
+ deps *resourceReconcileDriverDeps,
+) ([]resources.ProjectorRegistration, error) {
+ var registrations []resources.ProjectorRegistration
+ var err error
+ registrations, err = appendCoreProjectorRegistrations(registrations, deps)
+ if err != nil {
+ return nil, err
+ }
+ if deps.Automation != nil {
+ registrations, err = appendAutomationProjectorRegistrations(registrations, deps)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if deps.Bridges != nil {
+ registrations, err = appendBridgeProjectorRegistration(registrations, deps)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if deps.Bundles != nil {
+ registrations, err = appendBundleProjectorRegistrations(registrations, deps)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return registrations, nil
+}
+
+func appendCoreProjectorRegistrations(
+ registrations []resources.ProjectorRegistration,
+ deps *resourceReconcileDriverDeps,
+) ([]resources.ProjectorRegistration, error) {
+ if deps.Hooks != nil {
+ codec, err := resources.ResolveCodec[hookspkg.HookDecl](deps.CodecRegistry, hookBindingResourceKind)
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(codec, newHookBindingProjector(deps.Hooks))
+ if err != nil {
+ return nil, err
+ }
+ registrations = append(registrations, registration)
+ }
+ if deps.AgentCatalog != nil {
+ codec, err := resources.ResolveCodec[aghconfig.AgentDef](deps.CodecRegistry, aghconfig.AgentResourceKind)
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(codec, newAgentProjector(deps.AgentCatalog))
+ if err != nil {
+ return nil, err
+ }
+ registrations = append(registrations, registration)
+ }
+ if deps.ToolCatalog != nil {
+ codec, err := resources.ResolveCodec[toolspkg.Tool](deps.CodecRegistry, toolspkg.ToolResourceKind)
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(codec, newToolProjector(deps.ToolCatalog))
+ if err != nil {
+ return nil, err
+ }
+ registrations = append(registrations, registration)
+ }
+ if deps.MCPServerCatalog != nil {
+ codec, err := resources.ResolveCodec[aghconfig.MCPServer](
+ deps.CodecRegistry,
+ aghconfig.MCPServerResourceKind,
+ )
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(
+ codec,
+ newMCPServerProjector(deps.MCPServerCatalog),
+ )
+ if err != nil {
+ return nil, err
+ }
+ registrations = append(registrations, registration)
+ }
+ if deps.SkillsRegistry != nil {
+ codec, err := resources.ResolveCodec[skills.SkillResourceSpec](deps.CodecRegistry, skills.SkillResourceKind)
+ if err != nil {
+ return nil, err
+ }
+ registration, err := resources.NewTypedProjectorRegistration(codec, newSkillProjector(deps.SkillsRegistry))
+ if err != nil {
+ return nil, err
+ }
+ registrations = append(registrations, registration)
+ }
+ return registrations, nil
+}
+
+func appendAutomationProjectorRegistrations(
+ registrations []resources.ProjectorRegistration,
+ deps *resourceReconcileDriverDeps,
+) ([]resources.ProjectorRegistration, error) {
+ jobCodec, err := resources.ResolveCodec[automationpkg.Job](deps.CodecRegistry, automationpkg.JobResourceKind)
+ if err != nil {
+ return nil, err
+ }
+ jobRegistration, err := resources.NewTypedProjectorRegistration(
+ jobCodec,
+ newAutomationJobProjector(deps.Automation),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ triggerCodec, err := resources.ResolveCodec[automationpkg.Trigger](
+ deps.CodecRegistry,
+ automationpkg.TriggerResourceKind,
+ )
+ if err != nil {
+ return nil, err
+ }
+ triggerRegistration, err := resources.NewTypedProjectorRegistration(
+ triggerCodec,
+ newAutomationTriggerProjector(deps.Automation),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ registrations = append(registrations, jobRegistration, triggerRegistration)
+ return registrations, nil
+}
+
+func resourceReconcileActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "daemon-control",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "system"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
func (d *Daemon) applyServerFactoryDefaults() {
if d.httpFactory == nil {
d.httpFactory = func(_ context.Context, deps RuntimeDeps) (Server, error) {
@@ -567,7 +828,9 @@ func (d *Daemon) applyServerFactoryDefaults() {
httpapi.WithAutomation(deps.Automation),
httpapi.WithBridgeService(deps.Bridges),
httpapi.WithBundleService(deps.Bundles),
+ httpapi.WithResourceService(deps.Resources),
httpapi.WithWorkspaceResolver(deps.WorkspaceService),
+ httpapi.WithAgentCatalog(deps.AgentCatalog),
httpapi.WithSkillsRegistry(deps.SkillsRegistry),
httpapi.WithMemoryStore(deps.MemoryStore),
httpapi.WithDreamTrigger(deps.DreamTrigger),
@@ -589,7 +852,9 @@ func (d *Daemon) applyServerFactoryDefaults() {
udsapi.WithAutomation(deps.Automation),
udsapi.WithBridgeService(deps.Bridges),
udsapi.WithBundleService(deps.Bundles),
+ udsapi.WithResourceService(deps.Resources),
udsapi.WithWorkspaceResolver(deps.WorkspaceService),
+ udsapi.WithAgentCatalog(deps.AgentCatalog),
udsapi.WithSkillsRegistry(deps.SkillsRegistry),
udsapi.WithMemoryStore(deps.MemoryStore),
udsapi.WithDreamTrigger(deps.DreamTrigger),
@@ -676,21 +941,22 @@ func (d *Daemon) detachShutdownTargets() shutdownTargets {
defer d.mu.Unlock()
targets := shutdownTargets{
- sessions: d.sessions,
- network: d.network,
- hooks: d.hooks,
- extensions: d.extensions,
- automation: d.automation,
- bridges: d.bridges,
- httpServer: d.httpServer,
- udsServer: d.udsServer,
- registry: d.registry,
- lock: d.lock,
- closeLogger: d.closeLogger,
- infoPath: d.homePaths.DaemonInfo,
- dreamRuntime: d.dreamRuntime,
- skillsCancel: d.skillsCancel,
- skillsDone: d.skillsDone,
+ sessions: d.sessions,
+ network: d.network,
+ hooks: d.hooks,
+ extensions: d.extensions,
+ automation: d.automation,
+ resourceReconcile: d.resourceReconcile,
+ bridges: d.bridges,
+ httpServer: d.httpServer,
+ udsServer: d.udsServer,
+ registry: d.registry,
+ lock: d.lock,
+ closeLogger: d.closeLogger,
+ infoPath: d.homePaths.DaemonInfo,
+ dreamRuntime: d.dreamRuntime,
+ skillsCancel: d.skillsCancel,
+ skillsDone: d.skillsDone,
}
d.resetRuntimeStateLocked()
@@ -703,6 +969,7 @@ func (d *Daemon) resetRuntimeStateLocked() {
d.hooks = nil
d.extensions = nil
d.automation = nil
+ d.resourceReconcile = nil
d.httpServer = nil
d.udsServer = nil
d.observer = nil
@@ -716,6 +983,7 @@ func (d *Daemon) resetRuntimeStateLocked() {
d.closeLogger = func() error { return nil }
d.dreamRuntime = nil
d.workspaceResolver = nil
+ d.environmentRegistry = nil
d.skillsCancel = nil
d.skillsDone = nil
d.bridges = nil
@@ -735,6 +1003,9 @@ func (d *Daemon) shutdownRuntimeWorkers(ctx context.Context, targets shutdownTar
targets.dreamRuntime.Shutdown()
}
stopSkillsWatcher(targets.skillsCancel, targets.skillsDone)
+ if targets.resourceReconcile != nil {
+ appendWrappedError(errs, "daemon: close resource reconcile driver", targets.resourceReconcile.Close(ctx))
+ }
if targets.extensions != nil {
appendWrappedError(errs, "daemon: stop extensions", targets.extensions.Stop(ctx))
}
diff --git a/internal/daemon/daemon_integration_test.go b/internal/daemon/daemon_integration_test.go
index 81a198cce..1ad43d68a 100644
--- a/internal/daemon/daemon_integration_test.go
+++ b/internal/daemon/daemon_integration_test.go
@@ -27,6 +27,7 @@ import (
"github.com/pedronauck/agh/internal/memory"
"github.com/pedronauck/agh/internal/memory/consolidation"
"github.com/pedronauck/agh/internal/network"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/store/globaldb"
@@ -565,12 +566,33 @@ func TestBootPreservesAutomationEnabledOverlaysAcrossRestart(t *testing.T) {
}
}()
- storedJob, err := db.GetJob(testutil.Context(t), job.ID)
+ kernel, err := resources.NewKernel(db.DB())
if err != nil {
- t.Fatalf("GetJob() error = %v", err)
+ t.Fatalf("NewKernel() error = %v", err)
}
- if !storedJob.Enabled {
- t.Fatal("stored config job enabled default = false, want true")
+ jobCodec, err := automationpkg.NewJobResourceCodec()
+ if err != nil {
+ t.Fatalf("NewJobResourceCodec() error = %v", err)
+ }
+ jobStore, err := resources.NewStore(kernel, jobCodec)
+ if err != nil {
+ t.Fatalf("NewStore(job) error = %v", err)
+ }
+ triggerCodec, err := automationpkg.NewTriggerResourceCodec()
+ if err != nil {
+ t.Fatalf("NewTriggerResourceCodec() error = %v", err)
+ }
+ triggerStore, err := resources.NewStore(kernel, triggerCodec)
+ if err != nil {
+ t.Fatalf("NewStore(trigger) error = %v", err)
+ }
+
+ storedJob, err := jobStore.Get(testutil.Context(t), resourceReconcileActor(), job.ID)
+ if err != nil {
+ t.Fatalf("jobStore.Get() error = %v", err)
+ }
+ if !storedJob.Spec.Enabled {
+ t.Fatal("stored resource job enabled default = false, want true")
}
jobOverlay, err := db.GetJobEnabledOverlay(testutil.Context(t), job.ID)
if err != nil {
@@ -580,12 +602,12 @@ func TestBootPreservesAutomationEnabledOverlaysAcrossRestart(t *testing.T) {
t.Fatal("job overlay enabled_override = true, want false")
}
- storedTrigger, err := db.GetTrigger(testutil.Context(t), trigger.ID)
+ storedTrigger, err := triggerStore.Get(testutil.Context(t), resourceReconcileActor(), trigger.ID)
if err != nil {
- t.Fatalf("GetTrigger() error = %v", err)
+ t.Fatalf("triggerStore.Get() error = %v", err)
}
- if !storedTrigger.Enabled {
- t.Fatal("stored config trigger enabled default = false, want true")
+ if !storedTrigger.Spec.Enabled {
+ t.Fatal("stored resource trigger enabled default = false, want true")
}
triggerOverlay, err := db.GetTriggerEnabledOverlay(testutil.Context(t), trigger.ID)
if err != nil {
@@ -596,6 +618,154 @@ func TestBootPreservesAutomationEnabledOverlaysAcrossRestart(t *testing.T) {
}
}
+func TestBridgeResourceProjectionReconcilesWritesAndBootRebuild(t *testing.T) {
+ homePaths := integrationHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ cfg.Automation.Enabled = false
+ installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, "ext-bridge", daemonTestExtensionOptions{
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "telegram",
+ bridgeDisplayName: "Telegram",
+ }, true)
+
+ newDaemon := func() *Daemon {
+ d, err := New(
+ WithHomePaths(homePaths),
+ WithConfig(&cfg),
+ WithLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("New() error = %v", err)
+ }
+ d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) {
+ return &fakeSessionManager{}, nil
+ }
+ d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) {
+ return &fakeObserver{}, nil
+ }
+ d.newExtensionManager = func(extensionManagerDeps) extensionRuntime {
+ return nil
+ }
+ d.httpFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "http"}, nil
+ }
+ d.udsFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "uds"}, nil
+ }
+ return d
+ }
+
+ first := newDaemon()
+ if err := first.boot(testutil.Context(t)); err != nil {
+ t.Fatalf("first boot() error = %v", err)
+ }
+ dbSource, ok := first.registry.(extensionDBSource)
+ if !ok || dbSource.DB() == nil {
+ t.Fatal("first registry does not expose database handle")
+ }
+ kernel, err := resources.NewKernel(dbSource.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ bridgeCodec, err := bridgepkg.NewBridgeInstanceResourceCodec(bridgeProviderLookup(first.bridges))
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+ bridgeStore, err := resources.NewStore(kernel, bridgeCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(bridge.instance) error = %v", err)
+ }
+
+ operator := resourceReconcileActor()
+ spec := bridgeResourceIntegrationSpec("Projected Bridge", true)
+ record, err := bridgeStore.Put(testutil.Context(t), operator, resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: "brg-resource",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ Spec: spec,
+ })
+ if err != nil {
+ t.Fatalf("bridgeStore.Put(create) error = %v", err)
+ }
+ if err := first.resourceReconcile.Trigger(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceResourceKind,
+ resources.ReconcileReasonWrite,
+ ); err != nil {
+ t.Fatalf("resourceReconcile.Trigger(create) error = %v", err)
+ }
+ waitForDaemonBridgeInstance(t, first.bridges, "brg-resource", "Projected Bridge")
+
+ spec.DisplayName = "Updated Bridge"
+ record, err = bridgeStore.Put(testutil.Context(t), operator, resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: record.ID,
+ Scope: record.Scope,
+ ExpectedVersion: record.Version,
+ Spec: spec,
+ })
+ if err != nil {
+ t.Fatalf("bridgeStore.Put(update) error = %v", err)
+ }
+ if err := first.resourceReconcile.Trigger(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceResourceKind,
+ resources.ReconcileReasonWrite,
+ ); err != nil {
+ t.Fatalf("resourceReconcile.Trigger(update) error = %v", err)
+ }
+ waitForDaemonBridgeInstance(t, first.bridges, "brg-resource", "Updated Bridge")
+
+ if err := bridgeStore.Delete(testutil.Context(t), operator, record.ID, record.Version); err != nil {
+ t.Fatalf("bridgeStore.Delete() error = %v", err)
+ }
+ if err := first.resourceReconcile.Trigger(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceResourceKind,
+ resources.ReconcileReasonWrite,
+ ); err != nil {
+ t.Fatalf("resourceReconcile.Trigger(delete) error = %v", err)
+ }
+ waitForDaemonBridgeMissing(t, first.bridges, "brg-resource")
+
+ bootRecord, err := bridgeStore.Put(testutil.Context(t), operator, resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: "brg-boot",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ Spec: bridgeResourceIntegrationSpec("Boot Bridge", true),
+ })
+ if err != nil {
+ t.Fatalf("bridgeStore.Put(boot) error = %v", err)
+ }
+ if err := first.resourceReconcile.Trigger(
+ testutil.Context(t),
+ bridgepkg.BridgeInstanceResourceKind,
+ resources.ReconcileReasonWrite,
+ ); err != nil {
+ t.Fatalf("resourceReconcile.Trigger(boot) error = %v", err)
+ }
+ waitForDaemonBridgeInstance(t, first.bridges, bootRecord.ID, "Boot Bridge")
+ if err := first.registry.(interface {
+ DeleteBridgeInstance(context.Context, string) error
+ }).DeleteBridgeInstance(testutil.Context(t), bootRecord.ID); err != nil {
+ t.Fatalf("DeleteBridgeInstance(cache) error = %v", err)
+ }
+ waitForDaemonBridgeMissing(t, first.bridges, bootRecord.ID)
+ if err := first.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("first Shutdown() error = %v", err)
+ }
+
+ second := newDaemon()
+ if err := second.boot(testutil.Context(t)); err != nil {
+ t.Fatalf("second boot() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := second.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("second Shutdown() error = %v", err)
+ }
+ })
+ waitForDaemonBridgeInstance(t, second.bridges, bootRecord.ID, "Boot Bridge")
+}
+
func TestShutdownCancelsActiveAutomationPrompt(t *testing.T) {
homePaths := integrationHomePaths(t)
cfg := testConfig(t, homePaths)
@@ -1640,10 +1810,12 @@ func TestBootStartsBridgeExtensionWithBoundRuntime(t *testing.T) {
extensionName := "ext-bridge-daemon"
instanceID := "brg-daemon-init"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -1653,8 +1825,7 @@ func TestBootStartsBridgeExtensionWithBoundRuntime(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
- instance, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ instance := seedDaemonBridgeInstanceFixture(t, registry, bridgepkg.CreateInstanceRequest{
ID: instanceID,
Scope: bridgepkg.ScopeGlobal,
Platform: "slack",
@@ -1664,9 +1835,6 @@ func TestBootStartsBridgeExtensionWithBoundRuntime(t *testing.T) {
Status: bridgepkg.BridgeStatusReady,
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
})
- if err != nil {
- t.Fatalf("CreateInstance() error = %v", err)
- }
if err := registry.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{
BridgeInstanceID: instance.ID,
BindingName: "bot_token",
@@ -1746,10 +1914,12 @@ func TestBootStartsBridgeExtensionWithDefaultEnvSecretResolver(t *testing.T) {
extensionName := "ext-bridge-daemon-default-env"
instanceID := "brg-daemon-default-env"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -1759,8 +1929,7 @@ func TestBootStartsBridgeExtensionWithDefaultEnvSecretResolver(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
- instance, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ instance := seedDaemonBridgeInstanceFixture(t, registry, bridgepkg.CreateInstanceRequest{
ID: instanceID,
Scope: bridgepkg.ScopeGlobal,
Platform: "slack",
@@ -1770,9 +1939,6 @@ func TestBootStartsBridgeExtensionWithDefaultEnvSecretResolver(t *testing.T) {
Status: bridgepkg.BridgeStatusReady,
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
})
- if err != nil {
- t.Fatalf("CreateInstance() error = %v", err)
- }
if err := registry.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{
BridgeInstanceID: instance.ID,
BindingName: "bot_token",
@@ -1831,10 +1997,12 @@ func TestBootFailsWhenDefaultBridgeSecretEnvIsMissing(t *testing.T) {
extensionName := "ext-bridge-daemon-missing-env"
instanceID := "brg-daemon-missing-env"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -1844,8 +2012,7 @@ func TestBootFailsWhenDefaultBridgeSecretEnvIsMissing(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
- instance, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ instance := seedDaemonBridgeInstanceFixture(t, registry, bridgepkg.CreateInstanceRequest{
ID: instanceID,
Scope: bridgepkg.ScopeGlobal,
Platform: "slack",
@@ -1855,9 +2022,6 @@ func TestBootFailsWhenDefaultBridgeSecretEnvIsMissing(t *testing.T) {
Status: bridgepkg.BridgeStatusReady,
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
})
- if err != nil {
- t.Fatalf("CreateInstance() error = %v", err)
- }
if err := registry.PutBridgeSecretBinding(testutil.Context(t), bridgepkg.BridgeSecretBinding{
BridgeInstanceID: instance.ID,
BindingName: "bot_token",
@@ -1914,10 +2078,12 @@ func TestBootStartsBridgeExtensionWithMultipleOwnedInstances(t *testing.T) {
firstID := "brg-daemon-init-a"
secondID := "brg-daemon-init-b"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -1927,7 +2093,6 @@ func TestBootStartsBridgeExtensionWithMultipleOwnedInstances(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
for _, req := range []bridgepkg.CreateInstanceRequest{
{
ID: firstID,
@@ -1950,9 +2115,7 @@ func TestBootStartsBridgeExtensionWithMultipleOwnedInstances(t *testing.T) {
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
},
} {
- if _, err := bridgeRegistry.CreateInstance(testutil.Context(t), req); err != nil {
- t.Fatalf("CreateInstance(%q) error = %v", req.ID, err)
- }
+ seedDaemonBridgeInstanceFixture(t, registry, req)
}
for _, binding := range []bridgepkg.BridgeSecretBinding{
{
@@ -2053,10 +2216,12 @@ func TestCreateEnabledBridgeAfterBootReloadsErroredExtension(t *testing.T) {
extensionName := "ext-bridge-create"
instanceID := "brg-daemon-create"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("record_initialize", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -2140,10 +2305,12 @@ func TestBridgeRuntimeRestartPreservesRouteContinuity(t *testing.T) {
extensionName := "ext-bridge-restart"
instanceID := "brg-daemon-restart"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("exit_once_record_deliveries", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("exit_once_record_deliveries", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -2153,8 +2320,7 @@ func TestBridgeRuntimeRestartPreservesRouteContinuity(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
- if _, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ seedDaemonBridgeInstanceFixture(t, registry, bridgepkg.CreateInstanceRequest{
ID: instanceID,
Scope: bridgepkg.ScopeGlobal,
Platform: "slack",
@@ -2163,9 +2329,7 @@ func TestBridgeRuntimeRestartPreservesRouteContinuity(t *testing.T) {
Enabled: true,
Status: bridgepkg.BridgeStatusReady,
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
- }); err != nil {
- t.Fatalf("CreateInstance() error = %v", err)
- }
+ })
d, err := New(
WithHomePaths(homePaths),
@@ -2288,10 +2452,12 @@ func TestDaemonShutdownClosesBridgeRuntimeCleanly(t *testing.T) {
extensionName := "ext-bridge-shutdown"
instanceID := "brg-daemon-shutdown"
installExtensionForDaemonIntegration(t, homePaths.DatabaseFile, extensionName, daemonTestExtensionOptions{
- runtimeCommand: daemonExtensionHelperCommand(t),
- runtimeArgs: daemonExtensionHelperArgs(),
- runtimeEnv: daemonExtensionHelperScenarioEnv("slow_record_deliveries", markerPath),
- capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ runtimeCommand: daemonExtensionHelperCommand(t),
+ runtimeArgs: daemonExtensionHelperArgs(),
+ runtimeEnv: daemonExtensionHelperScenarioEnv("slow_record_deliveries", markerPath),
+ capabilities: []string{extensionprotocol.CapabilityProvideBridgeAdapter},
+ bridgePlatform: "slack",
+ bridgeDisplayName: "Slack",
actions: []string{
string(extensionprotocol.HostAPIMethodBridgesMessagesIngest),
string(extensionprotocol.HostAPIMethodBridgesInstancesGet),
@@ -2301,8 +2467,7 @@ func TestDaemonShutdownClosesBridgeRuntimeCleanly(t *testing.T) {
}, true)
registry := openDaemonIntegrationGlobalDB(t, homePaths.DatabaseFile)
- bridgeRegistry := bridgepkg.NewRegistry(registry)
- if _, err := bridgeRegistry.CreateInstance(testutil.Context(t), bridgepkg.CreateInstanceRequest{
+ seedDaemonBridgeInstanceFixture(t, registry, bridgepkg.CreateInstanceRequest{
ID: instanceID,
Scope: bridgepkg.ScopeGlobal,
Platform: "slack",
@@ -2311,9 +2476,7 @@ func TestDaemonShutdownClosesBridgeRuntimeCleanly(t *testing.T) {
Enabled: true,
Status: bridgepkg.BridgeStatusReady,
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
- }); err != nil {
- t.Fatalf("CreateInstance() error = %v", err)
- }
+ })
d, err := New(
WithHomePaths(homePaths),
@@ -2471,6 +2634,58 @@ func findAutomationTriggerByName(triggers []automationpkg.Trigger, name string)
return nil
}
+func bridgeResourceIntegrationSpec(displayName string, enabled bool) bridgepkg.BridgeInstanceSpec {
+ return bridgepkg.BridgeInstanceSpec{
+ Scope: bridgepkg.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-bridge",
+ DisplayName: displayName,
+ Source: bridgepkg.BridgeInstanceSourceDynamic,
+ Enabled: enabled,
+ DMPolicy: bridgepkg.BridgeDMPolicyPairing,
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ ProviderConfig: []byte(`{"tenant":"acme"}`),
+ DeliveryDefaults: []byte(`{"peer_id":"peer-default","mode":"reply"}`),
+ }
+}
+
+func waitForDaemonBridgeInstance(t *testing.T, runtime *bridgeRuntime, id string, displayName string) {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ instance, err := runtime.GetInstance(testutil.Context(t), id)
+ if err == nil && instance.DisplayName == displayName {
+ return
+ }
+ if time.Now().After(deadline) {
+ if err != nil {
+ t.Fatalf("GetInstance(%q) did not become available: %v", id, err)
+ }
+ t.Fatalf("GetInstance(%q).DisplayName did not become %q", id, displayName)
+ }
+ timer := time.NewTimer(10 * time.Millisecond)
+ <-timer.C
+ }
+}
+
+func waitForDaemonBridgeMissing(t *testing.T, runtime *bridgeRuntime, id string) {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ _, err := runtime.GetInstance(testutil.Context(t), id)
+ if errors.Is(err, bridgepkg.ErrBridgeInstanceNotFound) {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("GetInstance(%q) still exists or failed unexpectedly: %v", id, err)
+ }
+ timer := time.NewTimer(10 * time.Millisecond)
+ <-timer.C
+ }
+}
+
func writeDaemonHookScript(t *testing.T, dir string, name string, contents string) string {
t.Helper()
@@ -2534,6 +2749,53 @@ func openDaemonIntegrationGlobalDB(t *testing.T, databasePath string) *globaldb.
return db
}
+func seedDaemonBridgeInstanceFixture(
+ t *testing.T,
+ registry *globaldb.GlobalDB,
+ req bridgepkg.CreateInstanceRequest,
+) *bridgepkg.BridgeInstance {
+ t.Helper()
+
+ if registry == nil {
+ t.Fatal("seedDaemonBridgeInstanceFixture() registry = nil")
+ }
+
+ instance, err := bridgepkg.NewRegistry(registry).CreateInstance(testutil.Context(t), req)
+ if err != nil {
+ t.Fatalf("CreateInstance(%q) error = %v", strings.TrimSpace(req.ID), err)
+ }
+
+ kernel, err := resources.NewKernel(registry.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := bridgepkg.NewBridgeInstanceResourceCodec(
+ bridgeProviderLookup(newBridgeRuntime(registry, discardLogger(), nil, nil)),
+ )
+ if err != nil {
+ t.Fatalf("NewBridgeInstanceResourceCodec() error = %v", err)
+ }
+ resourceStore, err := resources.NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(bridge.instance) error = %v", err)
+ }
+
+ if _, err := resourceStore.Put(
+ testutil.Context(t),
+ resourceReconcileActor(),
+ resources.Draft[bridgepkg.BridgeInstanceSpec]{
+ ID: instance.ID,
+ Scope: bridgepkg.ResourceScopeForBridge(instance.Scope, instance.WorkspaceID),
+ ExpectedVersion: 0,
+ Spec: bridgepkg.BridgeInstanceSpecFromInstance(*instance),
+ },
+ ); err != nil {
+ t.Fatalf("bridge resource put(%q) error = %v", instance.ID, err)
+ }
+
+ return instance
+}
+
func readDaemonInitializeMarkers(t *testing.T, path string) []daemonInitializeMarker {
t.Helper()
@@ -2544,6 +2806,9 @@ func readDaemonInitializeMarkers(t *testing.T, path string) []daemonInitializeMa
markers := make([]daemonInitializeMarker, 0, len(lines))
for _, line := range lines {
+ if strings.TrimSpace(line) == "shutdown" {
+ continue
+ }
var marker daemonInitializeMarker
if err := json.Unmarshal([]byte(line), &marker); err != nil {
t.Fatalf("json.Unmarshal(initialize marker) error = %v; line=%q", err, line)
@@ -2563,6 +2828,9 @@ func readDaemonDeliveryMarkers(t *testing.T, path string) []daemonDeliveryMarker
markers := make([]daemonDeliveryMarker, 0, len(lines))
for _, line := range lines {
+ if strings.TrimSpace(line) == "shutdown" {
+ continue
+ }
var marker daemonDeliveryMarker
if err := json.Unmarshal([]byte(line), &marker); err != nil {
t.Fatalf("json.Unmarshal(delivery marker) error = %v; line=%q", err, line)
diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go
index 83b0862fe..7daf9c98d 100644
--- a/internal/daemon/daemon_test.go
+++ b/internal/daemon/daemon_test.go
@@ -34,6 +34,7 @@ import (
"github.com/pedronauck/agh/internal/network"
"github.com/pedronauck/agh/internal/observe"
"github.com/pedronauck/agh/internal/procutil"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
@@ -197,6 +198,149 @@ func TestBootWithNetworkDisabledKeepsDaemonOperational(t *testing.T) {
}
}
+func TestBootWithRegistryMissingResourceDBLeavesResourceServiceUnavailable(t *testing.T) {
+ homePaths := testHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ cfg.Network.Enabled = false
+
+ var httpSawNilResources bool
+ var udsSawNilResources bool
+
+ d := newTestDaemon(t, homePaths, &cfg)
+ d.openRegistry = func(context.Context, string) (Registry, error) {
+ return &recordingRegistry{path: homePaths.DatabaseFile}, nil
+ }
+ d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) {
+ return &fakeSessionManager{}, nil
+ }
+ d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) {
+ return &fakeObserver{}, nil
+ }
+ d.httpFactory = func(_ context.Context, deps RuntimeDeps) (Server, error) {
+ httpSawNilResources = deps.Resources == nil
+ return &fakeServer{name: "http"}, nil
+ }
+ d.udsFactory = func(_ context.Context, deps RuntimeDeps) (Server, error) {
+ udsSawNilResources = deps.Resources == nil
+ return &fakeServer{name: "uds"}, nil
+ }
+
+ if err := d.boot(testutil.Context(t)); err != nil {
+ t.Fatalf("boot() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := d.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("Shutdown() error = %v", err)
+ }
+ })
+
+ if !httpSawNilResources {
+ t.Fatal("httpFactory() received non-nil resource service, want nil when registry has no SQL handle")
+ }
+ if !udsSawNilResources {
+ t.Fatal("udsFactory() received non-nil resource service, want nil when registry has no SQL handle")
+ }
+}
+
+func TestBootRunsResourceReconcileBeforeObserverReconcile(t *testing.T) {
+ homePaths := testHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ cfg.Network.Enabled = false
+
+ var mu sync.Mutex
+ var order []string
+ appendOrder := func(step string) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, step)
+ }
+
+ driver := &fakeResourceReconcileDriver{
+ onRunBoot: func() {
+ appendOrder("driver")
+ },
+ }
+
+ d := newTestDaemon(t, homePaths, &cfg)
+ d.openRegistry = func(context.Context, string) (Registry, error) {
+ return &recordingRegistry{path: homePaths.DatabaseFile}, nil
+ }
+ d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) {
+ return &fakeSessionManager{}, nil
+ }
+ d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) {
+ return &fakeObserver{
+ onReconcile: func() {
+ appendOrder("observer")
+ },
+ }, nil
+ }
+ d.newResourceReconcile = func(context.Context, resourceReconcileDriverDeps) (resources.ReconcileDriver, error) {
+ return driver, nil
+ }
+ d.httpFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "http"}, nil
+ }
+ d.udsFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "uds"}, nil
+ }
+
+ if err := d.boot(testutil.Context(t)); err != nil {
+ t.Fatalf("boot() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := d.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("Shutdown() error = %v", err)
+ }
+ })
+
+ mu.Lock()
+ gotOrder := append([]string(nil), order...)
+ mu.Unlock()
+ wantOrder := []string{"driver", "observer"}
+ if !slices.Equal(gotOrder, wantOrder) {
+ t.Fatalf("boot order = %#v, want %#v", gotOrder, wantOrder)
+ }
+}
+
+func TestShutdownClosesResourceReconcileDriver(t *testing.T) {
+ homePaths := testHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ cfg.Network.Enabled = false
+
+ driver := &fakeResourceReconcileDriver{}
+ d := newTestDaemon(t, homePaths, &cfg)
+ d.openRegistry = func(context.Context, string) (Registry, error) {
+ return &recordingRegistry{path: homePaths.DatabaseFile}, nil
+ }
+ d.newSessionManager = func(context.Context, SessionManagerDeps) (SessionManager, error) {
+ return &fakeSessionManager{}, nil
+ }
+ d.newObserver = func(context.Context, RuntimeDeps) (Observer, error) {
+ return &fakeObserver{}, nil
+ }
+ d.newResourceReconcile = func(context.Context, resourceReconcileDriverDeps) (resources.ReconcileDriver, error) {
+ return driver, nil
+ }
+ d.httpFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "http"}, nil
+ }
+ d.udsFactory = func(context.Context, RuntimeDeps) (Server, error) {
+ return &fakeServer{name: "uds"}, nil
+ }
+
+ if err := d.boot(testutil.Context(t)); err != nil {
+ t.Fatalf("boot() error = %v", err)
+ }
+
+ if err := d.Shutdown(testutil.Context(t)); err != nil {
+ t.Fatalf("Shutdown() error = %v", err)
+ }
+ if got, want := driver.closeCalls, 1; got != want {
+ t.Fatalf("resource reconcile Close() calls = %d, want %d", got, want)
+ }
+}
+
func TestBootEnabledNetworkLateBindsSessionCallbacksAndPersistsSafeDiagnostics(t *testing.T) {
homePaths := testHomePaths(t)
cfg := testConfig(t, homePaths)
@@ -641,6 +785,283 @@ func TestBootExtensionsBuildsManagerDepsAndRebuildsHooks(t *testing.T) {
}
}
+func TestExtensionManagerDepsIncludeResourceHandlesAndTrigger(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ homePaths := testHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ logger := discardLogger()
+ memStore := memory.NewStore(t.TempDir())
+ skillsRegistry := skills.NewRegistry(skills.RegistryConfig{})
+ sessions := &fakeSessionManager{}
+ observer := &fakeObserver{}
+ automation := &fakeAutomationManager{}
+ reconcile := &fakeResourceReconcileDriver{}
+ bridges := &bridgeRuntime{broker: bridgepkg.NewBroker(nil)}
+ codecs := resources.NewCodecRegistry()
+ extRegistry := extensionpkg.NewRegistry(db.DB())
+
+ d := newTestDaemon(t, homePaths, &cfg)
+ deps := d.extensionManagerDeps(&bootState{
+ cfg: cfg,
+ logger: logger,
+ sessions: sessions,
+ deps: RuntimeDeps{},
+ memoryStore: memStore,
+ observer: observer,
+ skillsRegistry: skillsRegistry,
+ bridges: bridges,
+ resourceKernel: kernel,
+ resourceCodecs: codecs,
+ resourceReconcile: reconcile,
+ automation: automation,
+ }, extRegistry)
+
+ if deps.Registry != extRegistry {
+ t.Fatal("deps.Registry mismatch")
+ }
+ if deps.Sessions != sessions {
+ t.Fatal("deps.Sessions mismatch")
+ }
+ if deps.MemoryStore != memStore {
+ t.Fatal("deps.MemoryStore mismatch")
+ }
+ if deps.Observer != observer {
+ t.Fatal("deps.Observer mismatch")
+ }
+ if deps.SkillsRegistry != skillsRegistry {
+ t.Fatal("deps.SkillsRegistry mismatch")
+ }
+ if deps.Logger != logger {
+ t.Fatal("deps.Logger mismatch")
+ }
+ if deps.ResourceCodecs != codecs {
+ t.Fatal("deps.ResourceCodecs mismatch")
+ }
+ if got, ok := deps.ResourceStore.(*resources.Kernel); !ok || got != kernel {
+ t.Fatalf("deps.ResourceStore = %#v, want kernel-backed raw store", deps.ResourceStore)
+ }
+ if got, ok := deps.SourceSessions.(*resources.Kernel); !ok || got != kernel {
+ t.Fatalf("deps.SourceSessions = %#v, want kernel-backed source sessions", deps.SourceSessions)
+ }
+ if got := deps.Automation(); got != automation {
+ t.Fatalf("deps.Automation() = %#v, want automation runtime", got)
+ }
+ if err := deps.ResourceTrigger(
+ testutil.Context(t),
+ hookBindingResourceKind,
+ resources.ReconcileReasonWrite,
+ ); err != nil {
+ t.Fatalf("deps.ResourceTrigger() error = %v", err)
+ }
+ if reconcile.triggerCalls != 1 {
+ t.Fatalf("resource trigger calls = %d, want 1", reconcile.triggerCalls)
+ }
+ if reconcile.lastKind != hookBindingResourceKind || reconcile.lastReason != resources.ReconcileReasonWrite {
+ t.Fatalf(
+ "resource trigger = (%q, %q), want (%q, %q)",
+ reconcile.lastKind,
+ reconcile.lastReason,
+ hookBindingResourceKind,
+ resources.ReconcileReasonWrite,
+ )
+ }
+}
+
+func TestBootHooksBuildsResourceBackedRuntimeAndAttachesObserver(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := newHookBindingCodec()
+ if err != nil {
+ t.Fatalf("newHookBindingCodec() error = %v", err)
+ }
+ codecs := resources.NewCodecRegistry()
+ if err := resources.RegisterCodec(codecs, codec); err != nil {
+ t.Fatalf("RegisterCodec() error = %v", err)
+ }
+ store, err := newHookBindingStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("newHookBindingStore() error = %v", err)
+ }
+
+ homePaths := testHomePaths(t)
+ cfg := testConfig(t, homePaths)
+ observer := &hookAwareTestObserver{}
+ reconcile := &fakeResourceReconcileDriver{}
+ d := newTestDaemon(t, homePaths, &cfg)
+ state := &bootState{
+ cfg: cfg,
+ logger: discardLogger(),
+ notifier: newHooksNotifier(
+ discardLogger(),
+ func() time.Time { return time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC) },
+ ),
+ observer: observer,
+ skillsRegistry: skills.NewRegistry(skills.RegistryConfig{}),
+ resourceKernel: kernel,
+ resourceCodecs: codecs,
+ resourceReconcile: reconcile,
+ }
+ cleanup := &bootCleanup{}
+
+ if err := d.bootHooks(testutil.Context(t), state, cleanup); err != nil {
+ t.Fatalf("bootHooks() error = %v", err)
+ }
+ t.Cleanup(func() {
+ for i := len(cleanup.fns) - 1; i >= 0; i-- {
+ if err := cleanup.fns[i](testutil.Context(t)); err != nil {
+ t.Fatalf("cleanup[%d]() error = %v", i, err)
+ }
+ }
+ })
+
+ if observer.attached == nil {
+ t.Fatal("observer attached hooks = nil, want runtime source")
+ }
+ if state.hooks == nil || state.hookDispatcher == nil || state.hookBindings == nil {
+ t.Fatalf("hook state = %#v, want populated runtime, dispatcher, and bindings", state)
+ }
+ if len(cleanup.fns) < 2 {
+ t.Fatalf("cleanup fns = %d, want hook close plus skills watcher stop", len(cleanup.fns))
+ }
+ if reconcile.triggerCalls == 0 {
+ t.Fatal("resource reconcile trigger calls = 0, want resource-backed hook sync")
+ }
+
+ records, err := store.List(testutil.Context(t), resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "reader",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "reader"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }, resources.ResourceFilter{})
+ if err != nil {
+ t.Fatalf("store.List() error = %v", err)
+ }
+ if len(records) == 0 {
+ t.Fatal("store.List() count = 0, want native hook bindings")
+ }
+}
+
+func TestAttachExtensionRuntimeUsesHookBindingSyncBeforeRebuild(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ extRegistry := extensionpkg.NewRegistry(db.DB())
+ homePaths := testHomePaths(t)
+ d := newTestDaemon(t, homePaths, testConfigPtr(t, homePaths))
+ manager := &fakeExtensionRuntime{}
+
+ t.Run("syncs hook bindings when available", func(t *testing.T) {
+ t.Parallel()
+
+ syncCalls := 0
+ rebuilds := 0
+ state := &bootState{
+ logger: discardLogger(),
+ hookBindings: hookBindingPublisherFunc(func(context.Context) error { syncCalls++; return nil }),
+ hooks: &fakeHookRuntime{onRebuild: func(context.Context) error {
+ rebuilds++
+ return nil
+ }},
+ }
+
+ d.attachExtensionRuntime(testutil.Context(t), state, extRegistry, manager)
+
+ if syncCalls != 1 {
+ t.Fatalf("hook binding sync calls = %d, want 1", syncCalls)
+ }
+ if rebuilds != 0 {
+ t.Fatalf("hook rebuild count = %d, want 0", rebuilds)
+ }
+ if state.deps.Extensions == nil {
+ t.Fatal("state.deps.Extensions = nil, want extension service")
+ }
+ })
+
+ t.Run("falls back to rebuild without hook bindings", func(t *testing.T) {
+ t.Parallel()
+
+ rebuilds := 0
+ state := &bootState{
+ logger: discardLogger(),
+ hooks: &fakeHookRuntime{onRebuild: func(context.Context) error {
+ rebuilds++
+ return nil
+ }},
+ }
+
+ d.attachExtensionRuntime(testutil.Context(t), state, extRegistry, manager)
+
+ if rebuilds != 1 {
+ t.Fatalf("hook rebuild count = %d, want 1", rebuilds)
+ }
+ if state.deps.Extensions == nil {
+ t.Fatal("state.deps.Extensions = nil, want extension service")
+ }
+ })
+
+ t.Run("logs sync failures without rebuilding", func(t *testing.T) {
+ t.Parallel()
+
+ syncCalls := 0
+ rebuilds := 0
+ state := &bootState{
+ logger: discardLogger(),
+ hookBindings: hookBindingPublisherFunc(func(context.Context) error {
+ syncCalls++
+ return errors.New("boom")
+ }),
+ hooks: &fakeHookRuntime{onRebuild: func(context.Context) error {
+ rebuilds++
+ return nil
+ }},
+ }
+
+ d.attachExtensionRuntime(testutil.Context(t), state, extRegistry, manager)
+
+ if syncCalls != 1 {
+ t.Fatalf("hook binding sync calls = %d, want 1", syncCalls)
+ }
+ if rebuilds != 0 {
+ t.Fatalf("hook rebuild count = %d, want 0", rebuilds)
+ }
+ })
+}
+
+func TestNewDaemonExtensionServiceHandlesNilRegistryAndDefaults(t *testing.T) {
+ t.Parallel()
+
+ if svc := newDaemonExtensionService(nil, nil, nil, nil, nil, nil, aghconfig.HomePaths{}, nil, nil); svc != nil {
+ t.Fatalf("newDaemonExtensionService(nil) = %#v, want nil", svc)
+ }
+
+ db := openDaemonTestGlobalDB(t)
+ registry := extensionpkg.NewRegistry(db.DB())
+ if svc := newDaemonExtensionService(
+ registry,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ aghconfig.HomePaths{},
+ nil,
+ nil,
+ ); svc == nil {
+ t.Fatal("newDaemonExtensionService(defaults) = nil, want service")
+ }
+}
+
func TestBootExtensionsLogsStartFailureAndKeepsPartialRuntime(t *testing.T) {
t.Parallel()
@@ -1046,17 +1467,17 @@ func TestDaemonExtensionServiceInstallStatusAndDisable(t *testing.T) {
}
})
- rebuilds := 0
+ syncs := 0
fixedNow := time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC)
service := newDaemonExtensionService(
registry,
manager,
- &fakeHookRuntime{
- onRebuild: func(context.Context) error {
- rebuilds++
- return nil
- },
- },
+ fakeHookBindingPublisher(func(context.Context) error {
+ syncs++
+ return nil
+ }),
+ nil,
+ nil,
nil,
homePaths,
discardLogger(),
@@ -1122,6 +1543,22 @@ func TestDaemonExtensionServiceInstallStatusAndDisable(t *testing.T) {
t.Fatalf("disabled extension = %#v, want disabled extension", disabled)
}
+ enabled, err := service.Enable(testutil.Context(t), "service-ext")
+ if err != nil {
+ t.Fatalf("service.Enable() error = %v", err)
+ }
+ if enabled.State != "active" || !enabled.Enabled {
+ t.Fatalf("enabled extension = %#v, want active enabled extension", enabled)
+ }
+
+ disabled, err = service.Disable(testutil.Context(t), "service-ext")
+ if err != nil {
+ t.Fatalf("service.Disable(second) error = %v", err)
+ }
+ if disabled.State != "disabled" || disabled.Enabled {
+ t.Fatalf("disabled extension after second disable = %#v, want disabled extension", disabled)
+ }
+
listed, err := service.List(testutil.Context(t))
if err != nil {
t.Fatalf("service.List() error = %v", err)
@@ -1129,8 +1566,22 @@ func TestDaemonExtensionServiceInstallStatusAndDisable(t *testing.T) {
if len(listed) != 1 || listed[0].State != "disabled" {
t.Fatalf("listed extensions = %#v, want one disabled extension", listed)
}
- if rebuilds != 2 {
- t.Fatalf("hook rebuild count = %d, want 2", rebuilds)
+ if syncs != 4 {
+ t.Fatalf("hook binding sync count = %d, want 4", syncs)
+ }
+}
+
+func TestDaemonExtensionServiceCheckReadyErrors(t *testing.T) {
+ t.Parallel()
+
+ var nilService *daemonExtensionService
+ if err := nilService.checkReady(); err == nil {
+ t.Fatal("nil service checkReady() error = nil, want error")
+ }
+
+ service := &daemonExtensionService{homePaths: testHomePaths(t), logger: discardLogger(), now: time.Now}
+ if _, err := service.List(testutil.Context(t)); err == nil {
+ t.Fatal("List() without registry error = nil, want error")
}
}
@@ -2052,6 +2503,12 @@ func TestBootCreatesWorkspaceResolverAndInjectsSessionManager(t *testing.T) {
if capturedDeps.WorkspaceResolver == nil {
t.Fatal("boot() did not inject the session manager workspace resolver")
}
+ if capturedDeps.EnvironmentRegistry == nil {
+ t.Fatal("boot() did not inject the session manager environment registry")
+ }
+ if d.environmentRegistry == nil {
+ t.Fatal("boot() did not retain the daemon environment registry")
+ }
if capturedUDSDeps.WorkspaceService == nil {
t.Fatal("boot() did not inject the uds workspace service")
}
@@ -3230,9 +3687,10 @@ func (f *fakeNetworkRuntime) Shutdown(context.Context) error {
}
type fakeObserver struct {
- reconciled bool
- result store.ReconcileResult
- err error
+ reconciled bool
+ result store.ReconcileResult
+ err error
+ onReconcile func()
}
func (f *fakeObserver) QueryEvents(context.Context, store.EventSummaryQuery) ([]store.EventSummary, error) {
@@ -3260,6 +3718,9 @@ func (f *fakeObserver) Health(context.Context) (observe.Health, error) {
}
func (f *fakeObserver) Reconcile(context.Context) (store.ReconcileResult, error) {
+ if f.onReconcile != nil {
+ f.onReconcile()
+ }
f.reconciled = true
return f.result, f.err
}
@@ -3270,6 +3731,15 @@ func (f *fakeObserver) OnSessionStopped(context.Context, *session.Session) {}
func (f *fakeObserver) OnAgentEvent(context.Context, string, any) {}
+type hookAwareTestObserver struct {
+ fakeObserver
+ attached observe.HookCatalogSource
+}
+
+func (o *hookAwareTestObserver) AttachHooks(source observe.HookCatalogSource) {
+ o.attached = source
+}
+
type fakeServer struct {
name string
onShutdown func()
@@ -3286,6 +3756,44 @@ func (f *fakeServer) Shutdown(context.Context) error {
return nil
}
+type fakeResourceReconcileDriver struct {
+ runBootCalls int
+ closeCalls int
+ triggerCalls int
+ lastKind resources.ResourceKind
+ lastReason resources.ReconcileReason
+ triggerErr error
+ onRunBoot func()
+ onClose func()
+}
+
+func (f *fakeResourceReconcileDriver) Trigger(
+ _ context.Context,
+ kind resources.ResourceKind,
+ reason resources.ReconcileReason,
+) error {
+ f.triggerCalls++
+ f.lastKind = kind
+ f.lastReason = reason
+ return f.triggerErr
+}
+
+func (f *fakeResourceReconcileDriver) RunBoot(context.Context) error {
+ f.runBootCalls++
+ if f.onRunBoot != nil {
+ f.onRunBoot()
+ }
+ return nil
+}
+
+func (f *fakeResourceReconcileDriver) Close(context.Context) error {
+ f.closeCalls++
+ if f.onClose != nil {
+ f.onClose()
+ }
+ return nil
+}
+
type recordingRegistry struct {
path string
onClose func()
@@ -3819,9 +4327,14 @@ type fakeHookRuntime struct {
onMessageStart func(context.Context, hookspkg.MessageStartPayload) error
onMessageDelta func(context.Context, hookspkg.MessageDeltaPayload) error
onMessageEnd func(context.Context, hookspkg.MessageEndPayload) error
+ onToolPreCall func(context.Context, hookspkg.ToolPreCallPayload) error
+ onToolPostCall func(context.Context, hookspkg.ToolPostCallPayload) error
+ onToolPostError func(context.Context, hookspkg.ToolPostErrorPayload) error
+ onPermRequest func(context.Context, hookspkg.PermissionRequestPayload) error
+ onPermResolved func(context.Context, hookspkg.PermissionResolvedPayload) error
+ onPermDenied func(context.Context, hookspkg.PermissionDeniedPayload) error
onPreCompact func(context.Context, hookspkg.ContextPreCompactPayload) error
onPostCompact func(context.Context, hookspkg.ContextPostCompactPayload) error
- onAgentEvent func(context.Context, string, any)
}
func (f *fakeHookRuntime) Rebuild(ctx context.Context) error {
@@ -4037,6 +4550,66 @@ func (f *fakeHookRuntime) DispatchMessageEnd(
return payload, nil
}
+func (f *fakeHookRuntime) DispatchToolPreCall(
+ ctx context.Context,
+ payload hookspkg.ToolPreCallPayload,
+) (hookspkg.ToolPreCallPayload, error) {
+ if f.onToolPreCall != nil {
+ return payload, f.onToolPreCall(ctx, payload)
+ }
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchToolPostCall(
+ ctx context.Context,
+ payload hookspkg.ToolPostCallPayload,
+) (hookspkg.ToolPostCallPayload, error) {
+ if f.onToolPostCall != nil {
+ return payload, f.onToolPostCall(ctx, payload)
+ }
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchToolPostError(
+ ctx context.Context,
+ payload hookspkg.ToolPostErrorPayload,
+) (hookspkg.ToolPostErrorPayload, error) {
+ if f.onToolPostError != nil {
+ return payload, f.onToolPostError(ctx, payload)
+ }
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchPermissionRequest(
+ ctx context.Context,
+ payload hookspkg.PermissionRequestPayload,
+) (hookspkg.PermissionRequestPayload, error) {
+ if f.onPermRequest != nil {
+ return payload, f.onPermRequest(ctx, payload)
+ }
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchPermissionResolved(
+ ctx context.Context,
+ payload hookspkg.PermissionResolvedPayload,
+) (hookspkg.PermissionResolvedPayload, error) {
+ if f.onPermResolved != nil {
+ return payload, f.onPermResolved(ctx, payload)
+ }
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchPermissionDenied(
+ ctx context.Context,
+ payload hookspkg.PermissionDeniedPayload,
+) (hookspkg.PermissionDeniedPayload, error) {
+ if f.onPermDenied != nil {
+ return payload, f.onPermDenied(ctx, payload)
+ }
+ return payload, nil
+}
+
func (f *fakeHookRuntime) DispatchContextPreCompact(
ctx context.Context,
payload hookspkg.ContextPreCompactPayload,
@@ -4057,10 +4630,39 @@ func (f *fakeHookRuntime) DispatchContextPostCompact(
return payload, nil
}
-func (f *fakeHookRuntime) OnAgentEvent(ctx context.Context, sessionID string, event any) {
- if f.onAgentEvent != nil {
- f.onAgentEvent(ctx, sessionID, event)
- }
+func (f *fakeHookRuntime) DispatchEnvironmentPrepare(
+ _ context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+) (hookspkg.EnvironmentPreparePayload, error) {
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchEnvironmentReady(
+ _ context.Context,
+ payload hookspkg.EnvironmentReadyPayload,
+) (hookspkg.EnvironmentReadyPayload, error) {
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchEnvironmentSyncBefore(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchEnvironmentSyncAfter(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncAfterPayload,
+) (hookspkg.EnvironmentSyncAfterPayload, error) {
+ return payload, nil
+}
+
+func (f *fakeHookRuntime) DispatchEnvironmentStop(
+ _ context.Context,
+ payload hookspkg.EnvironmentStopPayload,
+) (hookspkg.EnvironmentStopPayload, error) {
+ return payload, nil
}
func testHookExecutorResolver(native map[string]hookspkg.Executor) hookspkg.ExecutorResolver {
@@ -4086,6 +4688,15 @@ type fakeDreamService struct {
runHook func(context.Context, memory.SessionSpawner, string) error
}
+type fakeHookBindingPublisher func(context.Context) error
+
+func (f fakeHookBindingPublisher) Sync(ctx context.Context) error {
+ if f == nil {
+ return nil
+ }
+ return f(ctx)
+}
+
func (f *fakeDreamService) ShouldRun() (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
diff --git a/internal/daemon/environment_reconcile.go b/internal/daemon/environment_reconcile.go
new file mode 100644
index 000000000..d48a88d77
--- /dev/null
+++ b/internal/daemon/environment_reconcile.go
@@ -0,0 +1,587 @@
+package daemon
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/session"
+ "github.com/pedronauck/agh/internal/store"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+const (
+ environmentReconcileStatePrepared = "prepared"
+ environmentReconcileStateDestroyed = "destroyed"
+)
+
+type environmentReconcileSession struct {
+ metaPath string
+ meta store.SessionMeta
+}
+
+func (d *Daemon) reconcileDaemonEnvironments(ctx context.Context, state *bootState) {
+ logger := environmentReconcileLogger(state)
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if state == nil {
+ logger.Warn("daemon: environment reconciliation skipped", "error", "boot state is required")
+ return
+ }
+ if state.environmentRegistry == nil {
+ logger.Warn("daemon: environment reconciliation skipped", "error", "environment registry is required")
+ return
+ }
+
+ sessions, err := d.loadEnvironmentReconcileSessions(state)
+ if err != nil {
+ logger.Warn("daemon: environment reconciliation failed to load sessions", "error", err)
+ return
+ }
+
+ for _, candidate := range sessions {
+ if err := ctx.Err(); err != nil {
+ logger.Warn("daemon: environment reconciliation canceled", "error", err)
+ return
+ }
+ d.reconcileDaemonEnvironmentSession(ctx, state, candidate)
+ }
+
+ logger.Info("daemon: environment reconciliation complete", "sessions", len(sessions))
+}
+
+func (d *Daemon) loadEnvironmentReconcileSessions(
+ state *bootState,
+) ([]environmentReconcileSession, error) {
+ entries, err := os.ReadDir(d.homePaths.SessionsDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name() < entries[j].Name()
+ })
+
+ logger := environmentReconcileLogger(state)
+ sessions := make([]environmentReconcileSession, 0, len(entries))
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ metaPath := store.SessionMetaFile(filepath.Join(d.homePaths.SessionsDir, entry.Name()))
+ meta, err := store.ReadSessionMeta(metaPath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ logger.Warn(
+ "daemon: environment reconciliation skipped unreadable session metadata",
+ "session_id", strings.TrimSpace(entry.Name()),
+ "path", metaPath,
+ "error", err,
+ )
+ continue
+ }
+ if !sessionHasRemoteEnvironment(meta) {
+ continue
+ }
+ sessions = append(sessions, environmentReconcileSession{metaPath: metaPath, meta: meta})
+ }
+ return sessions, nil
+}
+
+func (d *Daemon) reconcileDaemonEnvironmentSession(
+ ctx context.Context,
+ state *bootState,
+ candidate environmentReconcileSession,
+) {
+ meta := candidate.meta
+ envMeta := cloneDaemonSessionEnvironmentMeta(meta.Environment)
+ logger := environmentReconcileLogger(state)
+ if envMeta == nil {
+ return
+ }
+
+ backend := environment.Backend(strings.TrimSpace(envMeta.Backend))
+ provider, err := state.environmentRegistry.Provider(backend)
+ if err != nil {
+ logger.Warn(
+ "daemon: environment reconciliation provider unavailable",
+ environmentReconcileLogAttrs(meta, envMeta, 0, err)...,
+ )
+ return
+ }
+
+ resolvedWorkspace, workspaceResolved := d.resolveEnvironmentReconcileWorkspace(ctx, state, meta, envMeta)
+ resolvedEnv := environmentReconcileResolvedEnvironment(envMeta, resolvedWorkspace, workspaceResolved)
+ localRoot, localAdditional := environmentReconcileLocalRoots(resolvedWorkspace, workspaceResolved)
+
+ stateForProvider := environmentSessionStateFromMeta(envMeta)
+ if strings.TrimSpace(stateForProvider.InstanceID) == "" && strings.TrimSpace(envMeta.EnvironmentID) != "" {
+ if found, ok := d.findDaemonEnvironment(
+ ctx,
+ state,
+ provider,
+ meta,
+ envMeta,
+ resolvedEnv,
+ localRoot,
+ localAdditional,
+ ); ok {
+ stateForProvider = mergeEnvironmentSessionState(stateForProvider, found)
+ envMeta = environmentMetaFromSessionState(stateForProvider, envMeta.State)
+ }
+ }
+ if strings.TrimSpace(stateForProvider.InstanceID) == "" {
+ logger.Warn(
+ "daemon: environment reconciliation skipped missing instance",
+ environmentReconcileLogAttrs(
+ meta,
+ envMeta,
+ 0,
+ errors.New("environment instance id is required"),
+ )...,
+ )
+ return
+ }
+
+ if !sessionStateRecoverable(meta.State) {
+ d.destroyDaemonEnvironment(ctx, state, provider, candidate, envMeta, stateForProvider)
+ return
+ }
+
+ d.reattachDaemonEnvironment(
+ ctx,
+ state,
+ provider,
+ candidate,
+ envMeta,
+ stateForProvider,
+ resolvedEnv,
+ localRoot,
+ localAdditional,
+ )
+}
+
+func (d *Daemon) findDaemonEnvironment(
+ ctx context.Context,
+ state *bootState,
+ provider environment.Provider,
+ meta store.SessionMeta,
+ envMeta *store.SessionEnvironmentMeta,
+ resolvedEnv environment.Resolved,
+ localRoot string,
+ localAdditional []string,
+) (environment.SessionState, bool) {
+ finder, ok := provider.(environment.Finder)
+ if !ok {
+ return environment.SessionState{}, false
+ }
+
+ labels := map[string]string{
+ "agh_environment_id": strings.TrimSpace(envMeta.EnvironmentID),
+ }
+ found, err := finder.FindEnvironment(ctx, environment.FindEnvironmentRequest{
+ SessionID: strings.TrimSpace(meta.ID),
+ WorkspaceID: strings.TrimSpace(meta.WorkspaceID),
+ EnvironmentID: strings.TrimSpace(envMeta.EnvironmentID),
+ LocalRootDir: localRoot,
+ LocalAdditionalDirs: append([]string(nil), localAdditional...),
+ Environment: resolvedEnv,
+ ProviderState: cloneDaemonRawMessage(envMeta.ProviderState),
+ Labels: labels,
+ })
+ if err != nil {
+ attrs := environmentReconcileLogAttrs(meta, envMeta, 0, err)
+ if errors.Is(err, environment.ErrEnvironmentNotFound) {
+ environmentReconcileLogger(state).Info("daemon: environment reconciliation remote not found", attrs...)
+ } else {
+ environmentReconcileLogger(state).Warn("daemon: environment reconciliation remote lookup failed", attrs...)
+ }
+ return environment.SessionState{}, false
+ }
+ return found, true
+}
+
+func (d *Daemon) reattachDaemonEnvironment(
+ ctx context.Context,
+ state *bootState,
+ provider environment.Provider,
+ candidate environmentReconcileSession,
+ envMeta *store.SessionEnvironmentMeta,
+ providerState environment.SessionState,
+ resolvedEnv environment.Resolved,
+ localRoot string,
+ localAdditional []string,
+) {
+ meta := candidate.meta
+ started := time.Now()
+ prepared, err := provider.Prepare(ctx, environment.PrepareRequest{
+ SessionID: strings.TrimSpace(meta.ID),
+ WorkspaceID: strings.TrimSpace(meta.WorkspaceID),
+ EnvironmentID: strings.TrimSpace(envMeta.EnvironmentID),
+ InstanceID: strings.TrimSpace(providerState.InstanceID),
+ LocalRootDir: localRoot,
+ LocalAdditionalDirs: append([]string(nil), localAdditional...),
+ Environment: resolvedEnv,
+ ProviderState: cloneDaemonRawMessage(providerState.ProviderState),
+ })
+ duration := time.Since(started)
+ if err != nil {
+ environmentReconcileLogger(state).Warn(
+ "daemon: environment reattach failed",
+ environmentReconcileLogAttrs(meta, envMeta, duration, err)...,
+ )
+ if strings.TrimSpace(providerState.InstanceID) != "" {
+ d.destroyDaemonEnvironment(ctx, state, provider, candidate, envMeta, providerState)
+ }
+ return
+ }
+
+ nextState := mergeEnvironmentSessionState(providerState, prepared.State)
+ nextMeta := environmentMetaFromSessionState(nextState, environmentReconcileStatePrepared)
+ if nextMeta.Backend == "" {
+ nextMeta.Backend = envMeta.Backend
+ }
+ if nextMeta.Profile == "" {
+ nextMeta.Profile = envMeta.Profile
+ }
+ d.persistEnvironmentReconcileMeta(ctx, state, candidate, nextMeta)
+ environmentReconcileLogger(state).Info(
+ "daemon: environment reattach complete",
+ environmentReconcileLogAttrs(meta, nextMeta, duration, nil)...,
+ )
+}
+
+func (d *Daemon) destroyDaemonEnvironment(
+ ctx context.Context,
+ state *bootState,
+ provider environment.Provider,
+ candidate environmentReconcileSession,
+ envMeta *store.SessionEnvironmentMeta,
+ providerState environment.SessionState,
+) {
+ meta := candidate.meta
+ logger := environmentReconcileLogger(state)
+ if strings.TrimSpace(providerState.InstanceID) == "" {
+ logger.Warn(
+ "daemon: environment destroy skipped",
+ environmentReconcileLogAttrs(meta, envMeta, 0, errors.New("environment instance id is required"))...,
+ )
+ return
+ }
+
+ started := time.Now()
+ err := provider.Destroy(ctx, providerState)
+ duration := time.Since(started)
+ if err != nil {
+ logger.Warn(
+ "daemon: environment destroy failed",
+ environmentReconcileLogAttrs(meta, envMeta, duration, err)...,
+ )
+ return
+ }
+
+ nextMeta := environmentMetaFromSessionState(providerState, environmentReconcileStateDestroyed)
+ if nextMeta.Backend == "" {
+ nextMeta.Backend = envMeta.Backend
+ }
+ if nextMeta.Profile == "" {
+ nextMeta.Profile = envMeta.Profile
+ }
+ nextMeta.State = environmentReconcileStateDestroyed
+ d.persistEnvironmentReconcileMeta(ctx, state, candidate, nextMeta)
+ logger.Info(
+ "daemon: environment destroy complete",
+ environmentReconcileLogAttrs(meta, nextMeta, duration, nil)...,
+ )
+}
+
+func (d *Daemon) persistEnvironmentReconcileMeta(
+ ctx context.Context,
+ state *bootState,
+ candidate environmentReconcileSession,
+ envMeta *store.SessionEnvironmentMeta,
+) {
+ logger := environmentReconcileLogger(state)
+ next := candidate.meta
+ next.Environment = cloneDaemonSessionEnvironmentMeta(envMeta)
+ next.UpdatedAt = d.now().UTC()
+ if next.CreatedAt.IsZero() {
+ next.CreatedAt = next.UpdatedAt
+ }
+ if err := store.WriteSessionMeta(candidate.metaPath, next); err != nil {
+ logger.Warn(
+ "daemon: environment reconciliation metadata write failed",
+ environmentReconcileLogAttrs(candidate.meta, envMeta, 0, err)...,
+ )
+ return
+ }
+ if state == nil || state.registry == nil {
+ return
+ }
+ if err := state.registry.RegisterSession(ctx, sessionInfoFromEnvironmentReconcileMeta(next)); err != nil {
+ logger.Warn(
+ "daemon: environment reconciliation session index update failed",
+ environmentReconcileLogAttrs(candidate.meta, envMeta, 0, err)...,
+ )
+ }
+}
+
+func (d *Daemon) resolveEnvironmentReconcileWorkspace(
+ ctx context.Context,
+ state *bootState,
+ meta store.SessionMeta,
+ envMeta *store.SessionEnvironmentMeta,
+) (*workspacepkg.ResolvedWorkspace, bool) {
+ if state == nil || state.workspaceResolver == nil {
+ return nil, false
+ }
+ resolved, err := state.workspaceResolver.Resolve(ctx, strings.TrimSpace(meta.WorkspaceID))
+ if err != nil {
+ environmentReconcileLogger(state).Warn(
+ "daemon: environment reconciliation workspace resolve failed",
+ environmentReconcileLogAttrs(meta, envMeta, 0, err)...,
+ )
+ return nil, false
+ }
+ return &resolved, true
+}
+
+func sessionHasRemoteEnvironment(meta store.SessionMeta) bool {
+ if meta.Environment == nil {
+ return false
+ }
+ backend := strings.TrimSpace(meta.Environment.Backend)
+ if backend == "" {
+ backend = string(environment.BackendLocal)
+ }
+ return environment.Backend(backend) != environment.BackendLocal
+}
+
+func sessionStateRecoverable(state string) bool {
+ switch session.State(strings.TrimSpace(state)) {
+ case session.StateStarting, session.StateActive, session.StateStopping:
+ return true
+ default:
+ return false
+ }
+}
+
+func environmentReconcileResolvedEnvironment(
+ envMeta *store.SessionEnvironmentMeta,
+ resolvedWorkspace *workspacepkg.ResolvedWorkspace,
+ workspaceResolved bool,
+) environment.Resolved {
+ resolved := environment.Resolved{}
+ if workspaceResolved && resolvedWorkspace != nil {
+ resolved = resolvedWorkspace.Environment
+ }
+ backend := environment.Backend(strings.TrimSpace(envMeta.Backend))
+ if backend.Valid() {
+ resolved.Backend = backend
+ }
+ if !resolved.Backend.Valid() {
+ resolved.Backend = environment.BackendLocal
+ }
+ if profile := strings.TrimSpace(envMeta.Profile); profile != "" {
+ resolved.Profile = profile
+ }
+ if strings.TrimSpace(resolved.Profile) == "" {
+ resolved.Profile = string(resolved.Backend)
+ }
+ return resolved
+}
+
+func environmentReconcileLocalRoots(
+ resolvedWorkspace *workspacepkg.ResolvedWorkspace,
+ workspaceResolved bool,
+) (string, []string) {
+ if !workspaceResolved || resolvedWorkspace == nil {
+ return "", nil
+ }
+ return strings.TrimSpace(resolvedWorkspace.RootDir), append([]string(nil), resolvedWorkspace.AdditionalDirs...)
+}
+
+func environmentSessionStateFromMeta(meta *store.SessionEnvironmentMeta) environment.SessionState {
+ if meta == nil {
+ return environment.SessionState{}
+ }
+ return environment.SessionState{
+ EnvironmentID: strings.TrimSpace(meta.EnvironmentID),
+ Backend: environment.Backend(strings.TrimSpace(meta.Backend)),
+ Profile: strings.TrimSpace(meta.Profile),
+ State: strings.TrimSpace(meta.State),
+ InstanceID: strings.TrimSpace(meta.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(meta.RuntimeRootDir),
+ RuntimeAdditionalDirs: append([]string(nil), meta.RuntimeAdditionalDirs...),
+ ProviderState: cloneDaemonRawMessage(meta.ProviderState),
+ SSHAccessExpiresAt: cloneDaemonTimePointer(meta.SSHAccessExpiresAt),
+ }
+}
+
+func environmentMetaFromSessionState(
+ state environment.SessionState,
+ fallbackState string,
+) *store.SessionEnvironmentMeta {
+ envState := strings.TrimSpace(state.State)
+ if envState == "" || envState == "found" || envState == "ready" {
+ envState = fallbackState
+ }
+ return &store.SessionEnvironmentMeta{
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ State: envState,
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ RuntimeAdditionalDirs: append([]string(nil), state.RuntimeAdditionalDirs...),
+ ProviderState: cloneDaemonRawMessage(state.ProviderState),
+ SSHAccessExpiresAt: cloneDaemonTimePointer(state.SSHAccessExpiresAt),
+ }
+}
+
+func mergeEnvironmentSessionState(
+ base environment.SessionState,
+ overlay environment.SessionState,
+) environment.SessionState {
+ next := base
+ if strings.TrimSpace(overlay.EnvironmentID) != "" {
+ next.EnvironmentID = strings.TrimSpace(overlay.EnvironmentID)
+ }
+ if overlay.Backend.Valid() {
+ next.Backend = overlay.Backend
+ }
+ if strings.TrimSpace(overlay.Profile) != "" {
+ next.Profile = strings.TrimSpace(overlay.Profile)
+ }
+ if strings.TrimSpace(overlay.State) != "" {
+ next.State = strings.TrimSpace(overlay.State)
+ }
+ if strings.TrimSpace(overlay.InstanceID) != "" {
+ next.InstanceID = strings.TrimSpace(overlay.InstanceID)
+ }
+ if strings.TrimSpace(overlay.RuntimeRootDir) != "" {
+ next.RuntimeRootDir = strings.TrimSpace(overlay.RuntimeRootDir)
+ }
+ if len(overlay.RuntimeAdditionalDirs) > 0 {
+ next.RuntimeAdditionalDirs = append([]string(nil), overlay.RuntimeAdditionalDirs...)
+ }
+ if len(overlay.ProviderState) > 0 {
+ next.ProviderState = cloneDaemonRawMessage(overlay.ProviderState)
+ }
+ if overlay.SSHAccessExpiresAt != nil {
+ next.SSHAccessExpiresAt = cloneDaemonTimePointer(overlay.SSHAccessExpiresAt)
+ }
+ if !overlay.PreparedAt.IsZero() {
+ next.PreparedAt = overlay.PreparedAt
+ }
+ return next
+}
+
+func sessionInfoFromEnvironmentReconcileMeta(meta store.SessionMeta) store.SessionInfo {
+ stopReason := store.StopReason("")
+ if meta.StopReason != nil {
+ stopReason = *meta.StopReason
+ }
+ return store.SessionInfo{
+ ID: strings.TrimSpace(meta.ID),
+ Name: strings.TrimSpace(meta.Name),
+ AgentName: strings.TrimSpace(meta.AgentName),
+ WorkspaceID: strings.TrimSpace(meta.WorkspaceID),
+ Channel: strings.TrimSpace(meta.Channel),
+ SessionType: strings.TrimSpace(meta.SessionType),
+ State: strings.TrimSpace(meta.State),
+ ACPSessionID: cloneDaemonStringPointer(meta.ACPSessionID),
+ StopReason: stopReason,
+ StopDetail: strings.TrimSpace(meta.StopDetail),
+ Environment: cloneDaemonSessionEnvironmentMeta(meta.Environment),
+ CreatedAt: meta.CreatedAt,
+ UpdatedAt: meta.UpdatedAt,
+ }
+}
+
+func environmentReconcileLogAttrs(
+ meta store.SessionMeta,
+ envMeta *store.SessionEnvironmentMeta,
+ duration time.Duration,
+ err error,
+) []any {
+ attrs := []any{
+ "session_id", strings.TrimSpace(meta.ID),
+ "workspace_id", strings.TrimSpace(meta.WorkspaceID),
+ "session_state", strings.TrimSpace(meta.State),
+ "duration_ms", duration.Milliseconds(),
+ }
+ if envMeta != nil {
+ attrs = append(
+ attrs,
+ "backend", strings.TrimSpace(envMeta.Backend),
+ "profile", strings.TrimSpace(envMeta.Profile),
+ "environment_id", strings.TrimSpace(envMeta.EnvironmentID),
+ "instance_id", strings.TrimSpace(envMeta.InstanceID),
+ )
+ }
+ if err != nil {
+ attrs = append(attrs, "error", err)
+ }
+ return attrs
+}
+
+func environmentReconcileLogger(state *bootState) *slog.Logger {
+ if state != nil && state.logger != nil {
+ return state.logger
+ }
+ return slog.Default()
+}
+
+func cloneDaemonSessionEnvironmentMeta(
+ meta *store.SessionEnvironmentMeta,
+) *store.SessionEnvironmentMeta {
+ if meta == nil {
+ return nil
+ }
+ cloned := *meta
+ cloned.RuntimeAdditionalDirs = append([]string(nil), meta.RuntimeAdditionalDirs...)
+ cloned.ProviderState = cloneDaemonRawMessage(meta.ProviderState)
+ cloned.SSHAccessExpiresAt = cloneDaemonTimePointer(meta.SSHAccessExpiresAt)
+ cloned.LastSyncAt = cloneDaemonTimePointer(meta.LastSyncAt)
+ return &cloned
+}
+
+func cloneDaemonRawMessage(value json.RawMessage) json.RawMessage {
+ if value == nil {
+ return nil
+ }
+ cloned := make(json.RawMessage, len(value))
+ copy(cloned, value)
+ return cloned
+}
+
+func cloneDaemonTimePointer(value *time.Time) *time.Time {
+ if value == nil {
+ return nil
+ }
+ cloned := *value
+ return &cloned
+}
+
+func cloneDaemonStringPointer(value *string) *string {
+ if value == nil {
+ return nil
+ }
+ cloned := strings.TrimSpace(*value)
+ return &cloned
+}
diff --git a/internal/daemon/environment_reconcile_integration_test.go b/internal/daemon/environment_reconcile_integration_test.go
new file mode 100644
index 000000000..78847201a
--- /dev/null
+++ b/internal/daemon/environment_reconcile_integration_test.go
@@ -0,0 +1,134 @@
+//go:build integration
+
+package daemon
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/session"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestDaemonEnvironmentReconcileIntegrationBootFinalizeReattachesBeforeObserverReconcile(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-crashed-active",
+ state: session.StateActive,
+ env: remoteMeta("env-crashed-active", environment.BackendDaytona, "daytona", "sandbox-crashed-active"),
+ agent: "coder",
+ worker: "ws-crashed-active",
+ })
+
+ resourceReconcile := &fakeResourceReconcileDriver{
+ onRunBoot: func() {
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls during resource RunBoot = %d, want 0", got)
+ }
+ },
+ }
+ observer := &fakeObserver{
+ onReconcile: func() {
+ if got := len(provider.prepareRequests); got != 1 {
+ t.Fatalf("Prepare calls before observer Reconcile = %d, want 1", got)
+ }
+ },
+ }
+ state.resourceReconcile = resourceReconcile
+ state.observer = observer
+
+ if err := daemon.bootFinalize(testutil.Context(t), state); err != nil {
+ t.Fatalf("bootFinalize() error = %v", err)
+ }
+
+ if resourceReconcile.runBootCalls != 1 {
+ t.Fatalf("resource RunBoot calls = %d, want 1", resourceReconcile.runBootCalls)
+ }
+ if !observer.reconciled {
+ t.Fatal("observer Reconcile was not called")
+ }
+ req := provider.prepareRequests[0]
+ if req.EnvironmentID != "env-crashed-active" ||
+ req.InstanceID != "sandbox-crashed-active" ||
+ string(req.ProviderState) == "" {
+ t.Fatalf("PrepareRequest = %#v, want persisted environment identity and state", req)
+ }
+}
+
+func TestDaemonEnvironmentReconcileIntegrationPartialCreateFoundByEnvironmentID(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ provider.findState = environment.SessionState{
+ EnvironmentID: "env-timeout",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "found",
+ InstanceID: "sandbox-timeout",
+ ProviderState: json.RawMessage(`{"sandbox":"timeout"}`),
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-timeout",
+ state: session.StateActive,
+ env: remoteMeta("env-timeout", environment.BackendDaytona, "daytona", ""),
+ agent: "coder",
+ worker: "ws-timeout",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := provider.findRequests[0].Labels["agh_environment_id"]; got != "env-timeout" {
+ t.Fatalf("agh_environment_id lookup = %q, want env-timeout", got)
+ }
+ if got := provider.prepareRequests[0].InstanceID; got != "sandbox-timeout" {
+ t.Fatalf("PrepareRequest.InstanceID = %q, want sandbox-timeout", got)
+ }
+}
+
+func TestDaemonEnvironmentReconcileIntegrationUnrecoverableSandboxDestroyLogged(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ provider.prepareErr = errIntegrationReconcileProviderFailure{}
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-unrecoverable",
+ state: session.StateActive,
+ env: remoteMeta("env-unrecoverable", environment.BackendDaytona, "daytona", "sandbox-unrecoverable"),
+ agent: "coder",
+ worker: "ws-unrecoverable",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.destroyStates); got != 1 {
+ t.Fatalf("Destroy calls = %d, want 1", got)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment reattach failed") ||
+ !strings.Contains(logs.String(), "daemon: environment destroy complete") {
+ t.Fatalf("logs missing reattach failure and cleanup: %s", logs.String())
+ }
+}
+
+func TestDaemonEnvironmentReconcileIntegrationStoppedRemoteSessionDoesNotReattach(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-stopped-integration",
+ state: session.StateStopped,
+ env: remoteMeta("env-stopped-integration", environment.BackendDaytona, "daytona", "sandbox-stopped-integration"),
+ agent: "coder",
+ worker: "ws-stopped-integration",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0", got)
+ }
+ if got := len(provider.destroyStates); got != 1 {
+ t.Fatalf("Destroy calls = %d, want 1 cleanup attempt", got)
+ }
+}
+
+type errIntegrationReconcileProviderFailure struct{}
+
+func (errIntegrationReconcileProviderFailure) Error() string {
+ return "reattach failed"
+}
diff --git a/internal/daemon/environment_reconcile_test.go b/internal/daemon/environment_reconcile_test.go
new file mode 100644
index 000000000..0e597c024
--- /dev/null
+++ b/internal/daemon/environment_reconcile_test.go
@@ -0,0 +1,801 @@
+package daemon
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "maps"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/session"
+ "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/testutil"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+func TestReconcileDaemonEnvironmentsWithNoRemoteSessionsIsNoop(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-local",
+ state: session.StateActive,
+ env: remoteMeta("env-local", environment.BackendLocal, "local", ""),
+ agent: "coder",
+ worker: "ws-local",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0", got)
+ }
+ if got := len(provider.findRequests); got != 0 {
+ t.Fatalf("FindEnvironment calls = %d, want 0", got)
+ }
+ if got := len(provider.destroyStates); got != 0 {
+ t.Fatalf("Destroy calls = %d, want 0", got)
+ }
+}
+
+func TestReconcileDaemonEnvironmentsHandlesBootEdgeCases(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+
+ var nilBootContext context.Context
+ daemon.reconcileDaemonEnvironments(nilBootContext, nil)
+
+ logs.Reset()
+ state.environmentRegistry = nil
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if !strings.Contains(logs.String(), "environment registry is required") {
+ t.Fatalf("logs missing nil registry warning: %s", logs.String())
+ }
+
+ logs.Reset()
+ registry, err := environment.NewRegistry(provider)
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+ state.environmentRegistry = registry
+ daemon.homePaths.SessionsDir = filepath.Join(t.TempDir(), "missing-sessions")
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0 for missing sessions dir", got)
+ }
+
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-canceled",
+ state: session.StateActive,
+ env: remoteMeta("env-canceled", environment.BackendDaytona, "daytona", "sandbox-canceled"),
+ agent: "coder",
+ worker: "ws-canceled",
+ })
+ ctx, cancel := context.WithCancel(testutil.Context(t))
+ cancel()
+ daemon.reconcileDaemonEnvironments(ctx, state)
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0 after context cancellation", got)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment reconciliation canceled") {
+ t.Fatalf("logs missing cancellation warning: %s", logs.String())
+ }
+}
+
+func TestLoadEnvironmentReconcileSessionsSkipsUnreadableMetadata(t *testing.T) {
+ daemon, state, _, logs := newEnvironmentReconcileHarness(t)
+ sessionsDir := daemon.homePaths.SessionsDir
+ if err := os.MkdirAll(filepath.Join(sessionsDir, "sess-bad"), 0o755); err != nil {
+ t.Fatalf("MkdirAll() error = %v", err)
+ }
+ if err := os.WriteFile(
+ store.SessionMetaFile(filepath.Join(sessionsDir, "sess-bad")),
+ []byte("{not-json"),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sessionsDir, "not-a-session-dir"), []byte("ignored"), 0o644); err != nil {
+ t.Fatalf("WriteFile(non-dir) error = %v", err)
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-good",
+ state: session.StateActive,
+ env: remoteMeta("env-good", environment.BackendDaytona, "daytona", "sandbox-good"),
+ agent: "coder",
+ worker: "ws-good",
+ })
+
+ sessions, err := daemon.loadEnvironmentReconcileSessions(state)
+ if err != nil {
+ t.Fatalf("loadEnvironmentReconcileSessions() error = %v", err)
+ }
+ if got := len(sessions); got != 1 {
+ t.Fatalf("remote sessions = %d, want 1", got)
+ }
+ if got := sessions[0].meta.ID; got != "sess-good" {
+ t.Fatalf("remote session ID = %q, want sess-good", got)
+ }
+ if !strings.Contains(logs.String(), "skipped unreadable session metadata") {
+ t.Fatalf("logs missing unreadable metadata warning: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsReattachesRecoverableSession(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ provider.prepareState = environment.SessionState{
+ EnvironmentID: "env-remote",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "ready",
+ InstanceID: "sandbox-reattached",
+ ProviderState: json.RawMessage(`{"reattached":true}`),
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-active",
+ state: session.StateActive,
+ env: remoteMeta("env-remote", environment.BackendDaytona, "daytona", "sandbox-remote"),
+ agent: "coder",
+ worker: "ws-active",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.prepareRequests); got != 1 {
+ t.Fatalf("Prepare calls = %d, want 1", got)
+ }
+ req := provider.prepareRequests[0]
+ if req.EnvironmentID != "env-remote" {
+ t.Fatalf("PrepareRequest.EnvironmentID = %q, want env-remote", req.EnvironmentID)
+ }
+ if req.InstanceID != "sandbox-remote" {
+ t.Fatalf("PrepareRequest.InstanceID = %q, want sandbox-remote", req.InstanceID)
+ }
+ assertEnvironmentReconcileJSON(
+ t,
+ req.ProviderState,
+ json.RawMessage(`{"seed":true}`),
+ "PrepareRequest.ProviderState",
+ )
+
+ meta := readEnvironmentReconcileMeta(t, daemon, "sess-active")
+ if meta.Environment.InstanceID != "sandbox-reattached" {
+ t.Fatalf("persisted InstanceID = %q, want sandbox-reattached", meta.Environment.InstanceID)
+ }
+ assertEnvironmentReconcileJSON(
+ t,
+ meta.Environment.ProviderState,
+ json.RawMessage(`{"reattached":true}`),
+ "persisted ProviderState",
+ )
+ if meta.Environment.State != environmentReconcileStatePrepared {
+ t.Fatalf("persisted environment state = %q, want %q", meta.Environment.State, environmentReconcileStatePrepared)
+ }
+}
+
+func TestReconcileDaemonEnvironmentsFindsAndAttachesPartialCreate(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ provider.findState = environment.SessionState{
+ EnvironmentID: "env-partial",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "found",
+ InstanceID: "sandbox-found",
+ ProviderState: json.RawMessage(`{"found":true}`),
+ }
+ provider.prepareState = environment.SessionState{
+ EnvironmentID: "env-partial",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "ready",
+ InstanceID: "sandbox-found",
+ ProviderState: json.RawMessage(`{"attached":true}`),
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-partial",
+ state: session.StateActive,
+ env: remoteMeta("env-partial", environment.BackendDaytona, "daytona", ""),
+ agent: "coder",
+ worker: "ws-partial",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.findRequests); got != 1 {
+ t.Fatalf("FindEnvironment calls = %d, want 1", got)
+ }
+ if got := provider.findRequests[0].Labels["agh_environment_id"]; got != "env-partial" {
+ t.Fatalf("FindEnvironment label agh_environment_id = %q, want env-partial", got)
+ }
+ if got := len(provider.prepareRequests); got != 1 {
+ t.Fatalf("Prepare calls = %d, want 1", got)
+ }
+ if got := provider.prepareRequests[0].InstanceID; got != "sandbox-found" {
+ t.Fatalf("PrepareRequest.InstanceID = %q, want sandbox-found", got)
+ }
+ assertEnvironmentReconcileJSON(
+ t,
+ provider.prepareRequests[0].ProviderState,
+ json.RawMessage(`{"found":true}`),
+ "PrepareRequest.ProviderState",
+ )
+
+ meta := readEnvironmentReconcileMeta(t, daemon, "sess-partial")
+ if meta.Environment.InstanceID != "sandbox-found" {
+ t.Fatalf("persisted InstanceID = %q, want sandbox-found", meta.Environment.InstanceID)
+ }
+ assertEnvironmentReconcileJSON(
+ t,
+ meta.Environment.ProviderState,
+ json.RawMessage(`{"attached":true}`),
+ "persisted ProviderState",
+ )
+}
+
+func TestReconcileDaemonEnvironmentsDestroysUnrecoverablePartialCreate(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ provider.findState = environment.SessionState{
+ EnvironmentID: "env-stopped-partial",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "found",
+ InstanceID: "sandbox-stopped-partial",
+ ProviderState: json.RawMessage(`{"found":true}`),
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-stopped-partial",
+ state: session.StateStopped,
+ env: remoteMeta("env-stopped-partial", environment.BackendDaytona, "daytona", ""),
+ agent: "coder",
+ worker: "ws-stopped-partial",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0", got)
+ }
+ if got := len(provider.destroyStates); got != 1 {
+ t.Fatalf("Destroy calls = %d, want 1", got)
+ }
+ if got := provider.destroyStates[0].InstanceID; got != "sandbox-stopped-partial" {
+ t.Fatalf("Destroy state InstanceID = %q, want sandbox-stopped-partial", got)
+ }
+ meta := readEnvironmentReconcileMeta(t, daemon, "sess-stopped-partial")
+ if meta.Environment.State != environmentReconcileStateDestroyed {
+ t.Fatalf("persisted environment state = %q, want destroyed", meta.Environment.State)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment destroy complete") {
+ t.Fatalf("logs missing destroy completion: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsDestroysUnrecoverableSession(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-stopped",
+ state: session.StateStopped,
+ env: remoteMeta("env-stopped", environment.BackendDaytona, "daytona", "sandbox-stopped"),
+ agent: "coder",
+ worker: "ws-stopped",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.destroyStates); got != 1 {
+ t.Fatalf("Destroy calls = %d, want 1", got)
+ }
+ if got := provider.destroyStates[0].InstanceID; got != "sandbox-stopped" {
+ t.Fatalf("Destroy state InstanceID = %q, want sandbox-stopped", got)
+ }
+}
+
+func TestReconcileDaemonEnvironmentsUsesResolvedWorkspaceInputs(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ rootDir := t.TempDir()
+ additionalDir := t.TempDir()
+ expectedRootDir, err := filepath.EvalSymlinks(rootDir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(root) error = %v", err)
+ }
+ expectedRootDir, err = filepath.Abs(expectedRootDir)
+ if err != nil {
+ t.Fatalf("Abs(root) error = %v", err)
+ }
+ resolver, err := workspacepkg.NewResolver(
+ &environmentReconcileWorkspaceStore{
+ workspaces: map[string]workspacepkg.Workspace{
+ "ws-resolved": {
+ ID: "ws-resolved",
+ RootDir: rootDir,
+ AdditionalDirs: []string{additionalDir},
+ Name: "resolved",
+ EnvironmentRef: "daytona-dev",
+ CreatedAt: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC),
+ },
+ },
+ },
+ workspacepkg.WithConfigLoader(func(string) (aghconfig.Config, error) {
+ return aghconfig.Config{
+ Environments: map[string]aghconfig.EnvironmentProfile{
+ "daytona-dev": {
+ Backend: string(environment.BackendDaytona),
+ SyncMode: string(environment.SyncModeNone),
+ Daytona: aghconfig.DaytonaProfile{
+ Snapshot: "snap-reconcile",
+ },
+ },
+ },
+ }, nil
+ }),
+ workspacepkg.WithLogger(slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))),
+ )
+ if err != nil {
+ t.Fatalf("NewResolver() error = %v", err)
+ }
+ state.workspaceResolver = resolver
+ provider.prepareState = environment.SessionState{
+ EnvironmentID: "env-resolved",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona-dev",
+ State: "ready",
+ InstanceID: "sandbox-resolved",
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-resolved",
+ state: session.StateActive,
+ env: remoteMeta("env-resolved", environment.BackendDaytona, "daytona-dev", "sandbox-resolved"),
+ agent: "coder",
+ worker: "ws-resolved",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(provider.prepareRequests); got != 1 {
+ t.Fatalf("Prepare calls = %d, want 1", got)
+ }
+ req := provider.prepareRequests[0]
+ if req.LocalRootDir != expectedRootDir {
+ t.Fatalf("PrepareRequest.LocalRootDir = %q, want %q", req.LocalRootDir, expectedRootDir)
+ }
+ if len(req.LocalAdditionalDirs) != 1 || req.LocalAdditionalDirs[0] != additionalDir {
+ t.Fatalf("PrepareRequest.LocalAdditionalDirs = %#v, want [%q]", req.LocalAdditionalDirs, additionalDir)
+ }
+ if req.Environment.Profile != "daytona-dev" ||
+ req.Environment.Backend != environment.BackendDaytona ||
+ req.Environment.SyncMode != environment.SyncModeNone {
+ t.Fatalf("PrepareRequest.Environment = %#v, want resolved Daytona profile", req.Environment)
+ }
+}
+
+func TestReconcileDaemonEnvironmentsUnavailableProviderLogsAndContinues(t *testing.T) {
+ daemon, state, _, logs := newEnvironmentReconcileHarness(t)
+ emptyRegistry, err := environment.NewRegistry()
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+ state.environmentRegistry = emptyRegistry
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-provider-missing",
+ state: session.StateActive,
+ env: remoteMeta("env-provider-missing", environment.BackendDaytona, "daytona", "sandbox-missing"),
+ agent: "coder",
+ worker: "ws-provider-missing",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if !strings.Contains(logs.String(), "daemon: environment reconciliation provider unavailable") {
+ t.Fatalf("logs missing provider unavailable warning: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsFailureDoesNotBlockBoot(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ provider.prepareErr = errors.New("provider unavailable")
+ provider.destroyErr = errors.New("destroy unavailable")
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-failure",
+ state: session.StateActive,
+ env: remoteMeta("env-failure", environment.BackendDaytona, "daytona", "sandbox-failure"),
+ agent: "coder",
+ worker: "ws-failure",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if got := len(provider.prepareRequests); got != 1 {
+ t.Fatalf("Prepare calls = %d, want 1", got)
+ }
+ if got := len(provider.destroyStates); got != 1 {
+ t.Fatalf("Destroy calls = %d, want 1 cleanup attempt", got)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment reattach failed") ||
+ !strings.Contains(logs.String(), "daemon: environment destroy failed") {
+ t.Fatalf("logs missing non-blocking failure evidence: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsPersistsSessionIndexBestEffort(t *testing.T) {
+ daemon, state, provider, _ := newEnvironmentReconcileHarness(t)
+ registry := &environmentReconcileRegistry{}
+ state.registry = registry
+ provider.prepareState = environment.SessionState{
+ EnvironmentID: "env-indexed",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "ready",
+ InstanceID: "sandbox-indexed",
+ ProviderState: json.RawMessage(`{"indexed":true}`),
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-indexed",
+ state: session.StateActive,
+ env: remoteMeta("env-indexed", environment.BackendDaytona, "daytona", "sandbox-seed"),
+ agent: "coder",
+ worker: "ws-indexed",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if got := len(registry.sessions); got != 1 {
+ t.Fatalf("RegisterSession calls = %d, want 1", got)
+ }
+ indexed := registry.sessions[0]
+ if indexed.ID != "sess-indexed" || indexed.Environment == nil {
+ t.Fatalf("indexed session = %#v, want session with environment", indexed)
+ }
+ if indexed.Environment.InstanceID != "sandbox-indexed" {
+ t.Fatalf("indexed environment instance = %q, want sandbox-indexed", indexed.Environment.InstanceID)
+ }
+}
+
+func TestReconcileDaemonEnvironmentsLogsSessionIndexFailure(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ state.registry = &failingEnvironmentReconcileRegistry{}
+ provider.prepareState = environment.SessionState{
+ EnvironmentID: "env-index-fails",
+ Backend: environment.BackendDaytona,
+ Profile: "daytona",
+ State: "ready",
+ InstanceID: "sandbox-index-fails",
+ }
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-index-fails",
+ state: session.StateActive,
+ env: remoteMeta("env-index-fails", environment.BackendDaytona, "daytona", "sandbox-seed"),
+ agent: "coder",
+ worker: "ws-index-fails",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+
+ if !strings.Contains(logs.String(), "daemon: environment reconciliation session index update failed") {
+ t.Fatalf("logs missing session index failure: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsPartialCreateNotFoundDoesNotBlock(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ provider.findErr = environment.ErrEnvironmentNotFound
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-not-found",
+ state: session.StateActive,
+ env: remoteMeta("env-not-found", environment.BackendDaytona, "daytona", ""),
+ agent: "coder",
+ worker: "ws-not-found",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("Prepare calls = %d, want 0 without a discovered instance", got)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment reconciliation remote not found") {
+ t.Fatalf("logs missing remote not found info: %s", logs.String())
+ }
+}
+
+func TestReconcileDaemonEnvironmentsDestroySkippedWithoutInstance(t *testing.T) {
+ daemon, state, provider, logs := newEnvironmentReconcileHarness(t)
+ writeEnvironmentReconcileMeta(t, daemon, environmentReconcileMeta{
+ id: "sess-no-instance",
+ state: session.StateStopped,
+ env: remoteMeta("env-no-instance", environment.BackendDaytona, "daytona", ""),
+ agent: "coder",
+ worker: "ws-no-instance",
+ })
+
+ daemon.reconcileDaemonEnvironments(testutil.Context(t), state)
+ if got := len(provider.destroyStates); got != 0 {
+ t.Fatalf("Destroy calls = %d, want 0", got)
+ }
+ if !strings.Contains(logs.String(), "daemon: environment reconciliation skipped missing instance") {
+ t.Fatalf("logs missing missing instance warning: %s", logs.String())
+ }
+}
+
+type environmentReconcileMeta struct {
+ id string
+ state session.State
+ env *store.SessionEnvironmentMeta
+ agent string
+ worker string
+}
+
+func newEnvironmentReconcileHarness(
+ t *testing.T,
+) (*Daemon, *bootState, *recordingEnvironmentReconcileProvider, *bytes.Buffer) {
+ t.Helper()
+
+ logs := &bytes.Buffer{}
+ logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ provider := &recordingEnvironmentReconcileProvider{backend: environment.BackendDaytona}
+ registry, err := environment.NewRegistry(provider)
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+ daemon := &Daemon{
+ homePaths: aghconfig.HomePaths{SessionsDir: filepath.Join(t.TempDir(), "sessions")},
+ now: func() time.Time {
+ return time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ },
+ }
+ state := &bootState{
+ logger: logger,
+ environmentRegistry: registry,
+ }
+ return daemon, state, provider, logs
+}
+
+func writeEnvironmentReconcileMeta(t *testing.T, daemon *Daemon, spec environmentReconcileMeta) {
+ t.Helper()
+ meta := store.SessionMeta{
+ ID: spec.id,
+ AgentName: spec.agent,
+ WorkspaceID: spec.worker,
+ SessionType: string(session.SessionTypeUser),
+ State: string(spec.state),
+ Environment: spec.env,
+ CreatedAt: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC),
+ }
+ path := store.SessionMetaFile(filepath.Join(daemon.homePaths.SessionsDir, spec.id))
+ if err := store.WriteSessionMeta(path, meta); err != nil {
+ t.Fatalf("WriteSessionMeta() error = %v", err)
+ }
+}
+
+func readEnvironmentReconcileMeta(t *testing.T, daemon *Daemon, sessionID string) store.SessionMeta {
+ t.Helper()
+ meta, err := store.ReadSessionMeta(store.SessionMetaFile(filepath.Join(daemon.homePaths.SessionsDir, sessionID)))
+ if err != nil {
+ t.Fatalf("ReadSessionMeta() error = %v", err)
+ }
+ return meta
+}
+
+func remoteMeta(
+ environmentID string,
+ backend environment.Backend,
+ profile string,
+ instanceID string,
+) *store.SessionEnvironmentMeta {
+ return &store.SessionEnvironmentMeta{
+ EnvironmentID: environmentID,
+ Backend: string(backend),
+ Profile: profile,
+ State: "creating",
+ InstanceID: instanceID,
+ ProviderState: json.RawMessage(`{"seed":true}`),
+ }
+}
+
+func assertEnvironmentReconcileJSON(t *testing.T, got json.RawMessage, want json.RawMessage, label string) {
+ t.Helper()
+ var gotValue any
+ if err := json.Unmarshal(got, &gotValue); err != nil {
+ t.Fatalf("%s got invalid JSON %s: %v", label, got, err)
+ }
+ var wantValue any
+ if err := json.Unmarshal(want, &wantValue); err != nil {
+ t.Fatalf("%s want invalid JSON %s: %v", label, want, err)
+ }
+ if !jsonValuesEqual(gotValue, wantValue) {
+ t.Fatalf("%s = %s, want %s", label, got, want)
+ }
+}
+
+func jsonValuesEqual(left any, right any) bool {
+ leftRaw, leftErr := json.Marshal(left)
+ rightRaw, rightErr := json.Marshal(right)
+ return leftErr == nil && rightErr == nil && bytes.Equal(leftRaw, rightRaw)
+}
+
+type recordingEnvironmentReconcileProvider struct {
+ backend environment.Backend
+ prepareRequests []environment.PrepareRequest
+ findRequests []environment.FindEnvironmentRequest
+ destroyStates []environment.SessionState
+ prepareState environment.SessionState
+ findState environment.SessionState
+ prepareErr error
+ findErr error
+ destroyErr error
+}
+
+type environmentReconcileRegistry struct {
+ recordingRegistry
+ sessions []store.SessionInfo
+}
+
+func (r *environmentReconcileRegistry) RegisterSession(_ context.Context, session store.SessionInfo) error {
+ r.sessions = append(r.sessions, session)
+ return nil
+}
+
+type failingEnvironmentReconcileRegistry struct {
+ recordingRegistry
+}
+
+func (r *failingEnvironmentReconcileRegistry) RegisterSession(context.Context, store.SessionInfo) error {
+ return errors.New("index unavailable")
+}
+
+type environmentReconcileWorkspaceStore struct {
+ workspaces map[string]workspacepkg.Workspace
+}
+
+func (s *environmentReconcileWorkspaceStore) InsertWorkspace(context.Context, workspacepkg.Workspace) error {
+ return nil
+}
+
+func (s *environmentReconcileWorkspaceStore) UpdateWorkspace(_ context.Context, ws workspacepkg.Workspace) error {
+ s.workspaces[ws.ID] = ws
+ return nil
+}
+
+func (s *environmentReconcileWorkspaceStore) DeleteWorkspace(context.Context, string) error {
+ return nil
+}
+
+func (s *environmentReconcileWorkspaceStore) GetWorkspace(
+ _ context.Context,
+ id string,
+) (workspacepkg.Workspace, error) {
+ if ws, ok := s.workspaces[id]; ok {
+ return ws, nil
+ }
+ return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound
+}
+
+func (s *environmentReconcileWorkspaceStore) GetWorkspaceByPath(
+ _ context.Context,
+ rootDir string,
+) (workspacepkg.Workspace, error) {
+ for _, ws := range s.workspaces {
+ if ws.RootDir == rootDir {
+ return ws, nil
+ }
+ }
+ return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound
+}
+
+func (s *environmentReconcileWorkspaceStore) GetWorkspaceByName(
+ _ context.Context,
+ name string,
+) (workspacepkg.Workspace, error) {
+ for _, ws := range s.workspaces {
+ if ws.Name == name {
+ return ws, nil
+ }
+ }
+ return workspacepkg.Workspace{}, workspacepkg.ErrWorkspaceNotFound
+}
+
+func (s *environmentReconcileWorkspaceStore) ListWorkspaces(context.Context) ([]workspacepkg.Workspace, error) {
+ workspaces := make([]workspacepkg.Workspace, 0, len(s.workspaces))
+ for _, ws := range s.workspaces {
+ workspaces = append(workspaces, ws)
+ }
+ return workspaces, nil
+}
+
+func (p *recordingEnvironmentReconcileProvider) Backend() environment.Backend {
+ return p.backend
+}
+
+func (p *recordingEnvironmentReconcileProvider) Prepare(
+ _ context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ p.prepareRequests = append(p.prepareRequests, clonePrepareRequest(req))
+ if p.prepareErr != nil {
+ return environment.Prepared{}, p.prepareErr
+ }
+ state := p.prepareState
+ if strings.TrimSpace(state.EnvironmentID) == "" {
+ state.EnvironmentID = req.EnvironmentID
+ }
+ if !state.Backend.Valid() {
+ state.Backend = p.backend
+ }
+ if strings.TrimSpace(state.Profile) == "" {
+ state.Profile = req.Environment.Profile
+ }
+ if strings.TrimSpace(state.InstanceID) == "" {
+ state.InstanceID = req.InstanceID
+ }
+ if len(state.ProviderState) == 0 {
+ state.ProviderState = append(json.RawMessage(nil), req.ProviderState...)
+ }
+ return environment.Prepared{State: state}, nil
+}
+
+func (p *recordingEnvironmentReconcileProvider) SyncToRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ return environment.SyncResult{}, nil
+}
+
+func (p *recordingEnvironmentReconcileProvider) SyncFromRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ return environment.SyncResult{}, nil
+}
+
+func (p *recordingEnvironmentReconcileProvider) Destroy(
+ _ context.Context,
+ state environment.SessionState,
+) error {
+ p.destroyStates = append(p.destroyStates, cloneSessionState(state))
+ return p.destroyErr
+}
+
+func (p *recordingEnvironmentReconcileProvider) FindEnvironment(
+ _ context.Context,
+ req environment.FindEnvironmentRequest,
+) (environment.SessionState, error) {
+ p.findRequests = append(p.findRequests, cloneFindRequest(req))
+ if p.findErr != nil {
+ return environment.SessionState{}, p.findErr
+ }
+ return cloneSessionState(p.findState), nil
+}
+
+func clonePrepareRequest(req environment.PrepareRequest) environment.PrepareRequest {
+ cloned := req
+ cloned.LocalAdditionalDirs = append([]string(nil), req.LocalAdditionalDirs...)
+ cloned.AgentEnv = append([]string(nil), req.AgentEnv...)
+ cloned.ProviderState = append(json.RawMessage(nil), req.ProviderState...)
+ return cloned
+}
+
+func cloneFindRequest(req environment.FindEnvironmentRequest) environment.FindEnvironmentRequest {
+ cloned := req
+ cloned.LocalAdditionalDirs = append([]string(nil), req.LocalAdditionalDirs...)
+ cloned.ProviderState = append(json.RawMessage(nil), req.ProviderState...)
+ if req.Labels != nil {
+ cloned.Labels = make(map[string]string, len(req.Labels))
+ maps.Copy(cloned.Labels, req.Labels)
+ }
+ return cloned
+}
+
+func cloneSessionState(state environment.SessionState) environment.SessionState {
+ cloned := state
+ cloned.RuntimeAdditionalDirs = append([]string(nil), state.RuntimeAdditionalDirs...)
+ cloned.ProviderState = append(json.RawMessage(nil), state.ProviderState...)
+ if state.SSHAccessExpiresAt != nil {
+ expires := *state.SSHAccessExpiresAt
+ cloned.SSHAccessExpiresAt = &expires
+ }
+ return cloned
+}
diff --git a/internal/daemon/extensions.go b/internal/daemon/extensions.go
index 15978018a..61aa414e3 100644
--- a/internal/daemon/extensions.go
+++ b/internal/daemon/extensions.go
@@ -15,13 +15,15 @@ import (
)
type daemonExtensionService struct {
- registry *extensionpkg.Registry
- runtime extensionRuntime
- hooks hookRuntime
- bundles interface{ Reconcile(context.Context) error }
- homePaths aghconfig.HomePaths
- logger *slog.Logger
- now func() time.Time
+ registry *extensionpkg.Registry
+ runtime extensionRuntime
+ hookBinds hookBindingPublisher
+ agentSkill agentSkillPublisher
+ toolMCP toolMCPPublisher
+ bundles bundleResourcePublisher
+ homePaths aghconfig.HomePaths
+ logger *slog.Logger
+ now func() time.Time
}
var _ udsapi.ExtensionService = (*daemonExtensionService)(nil)
@@ -29,8 +31,10 @@ var _ udsapi.ExtensionService = (*daemonExtensionService)(nil)
func newDaemonExtensionService(
registry *extensionpkg.Registry,
runtime extensionRuntime,
- hooks hookRuntime,
- bundles interface{ Reconcile(context.Context) error },
+ hookBinds hookBindingPublisher,
+ agentSkill agentSkillPublisher,
+ toolMCP toolMCPPublisher,
+ bundles bundleResourcePublisher,
homePaths aghconfig.HomePaths,
logger *slog.Logger,
now func() time.Time,
@@ -47,18 +51,20 @@ func newDaemonExtensionService(
}
}
return &daemonExtensionService{
- registry: registry,
- runtime: runtime,
- hooks: hooks,
- bundles: bundles,
- homePaths: homePaths,
- logger: logger,
- now: now,
+ registry: registry,
+ runtime: runtime,
+ hookBinds: hookBinds,
+ agentSkill: agentSkill,
+ toolMCP: toolMCP,
+ bundles: bundles,
+ homePaths: homePaths,
+ logger: logger,
+ now: now,
}
}
func (s *daemonExtensionService) List(ctx context.Context) ([]contract.ExtensionPayload, error) {
- if err := s.checkReady(ctx); err != nil {
+ if err := s.checkReady(); err != nil {
return nil, err
}
@@ -82,7 +88,7 @@ func (s *daemonExtensionService) Install(
ctx context.Context,
req contract.InstallExtensionRequest,
) (contract.ExtensionPayload, error) {
- if err := s.checkReady(ctx); err != nil {
+ if err := s.checkReady(); err != nil {
return contract.ExtensionPayload{}, err
}
@@ -100,7 +106,7 @@ func (s *daemonExtensionService) Install(
}
func (s *daemonExtensionService) Enable(ctx context.Context, name string) (contract.ExtensionPayload, error) {
- if err := s.checkReady(ctx); err != nil {
+ if err := s.checkReady(); err != nil {
return contract.ExtensionPayload{}, err
}
if err := s.registry.Enable(name); err != nil {
@@ -113,7 +119,7 @@ func (s *daemonExtensionService) Enable(ctx context.Context, name string) (contr
}
func (s *daemonExtensionService) Disable(ctx context.Context, name string) (contract.ExtensionPayload, error) {
- if err := s.checkReady(ctx); err != nil {
+ if err := s.checkReady(); err != nil {
return contract.ExtensionPayload{}, err
}
if err := s.registry.Disable(name); err != nil {
@@ -125,8 +131,8 @@ func (s *daemonExtensionService) Disable(ctx context.Context, name string) (cont
return s.Status(ctx, name)
}
-func (s *daemonExtensionService) Status(ctx context.Context, name string) (contract.ExtensionPayload, error) {
- if err := s.checkReady(ctx); err != nil {
+func (s *daemonExtensionService) Status(_ context.Context, name string) (contract.ExtensionPayload, error) {
+ if err := s.checkReady(); err != nil {
return contract.ExtensionPayload{}, err
}
@@ -143,18 +149,20 @@ func (s *daemonExtensionService) reload(ctx context.Context) error {
}
reloadErr := s.runtime.Reload(ctx)
- if s.hooks == nil {
- if s.bundles == nil {
- return reloadErr
- }
- return errors.Join(reloadErr, s.bundles.Reconcile(ctx))
+ var syncErr error
+ if s.agentSkill != nil {
+ syncErr = errors.Join(syncErr, s.agentSkill.Sync(ctx))
}
-
- rebuildErr := s.hooks.Rebuild(ctx)
- if s.bundles == nil {
- return errors.Join(reloadErr, rebuildErr)
+ if s.hookBinds != nil {
+ syncErr = errors.Join(syncErr, s.hookBinds.Sync(ctx))
+ }
+ if s.toolMCP != nil {
+ syncErr = errors.Join(syncErr, s.toolMCP.Sync(ctx))
}
- return errors.Join(reloadErr, rebuildErr, s.bundles.Reconcile(ctx))
+ if s.bundles != nil {
+ syncErr = errors.Join(syncErr, s.bundles.Sync(ctx))
+ }
+ return errors.Join(reloadErr, syncErr)
}
func (s *daemonExtensionService) lookup(name string) (*extensionpkg.Extension, error) {
@@ -235,13 +243,10 @@ func (s *daemonExtensionService) payloadFromExtension(ext *extensionpkg.Extensio
return extensionpkg.DescribeExtension(ext, s.runtime != nil, s.now())
}
-func (s *daemonExtensionService) checkReady(ctx context.Context) error {
+func (s *daemonExtensionService) checkReady() error {
if s == nil {
return errors.New("daemon: extension service is required")
}
- if ctx == nil {
- return errors.New("daemon: extension service context is required")
- }
if s.registry == nil {
return errors.New("daemon: extension registry is required")
}
diff --git a/internal/daemon/hook_agent_events.go b/internal/daemon/hook_agent_events.go
new file mode 100644
index 000000000..98e5dc2fd
--- /dev/null
+++ b/internal/daemon/hook_agent_events.go
@@ -0,0 +1,337 @@
+package daemon
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/acp"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+)
+
+type hookAgentToolPayload struct {
+ SessionUpdate string `json:"sessionUpdate"`
+ Status string `json:"status,omitempty"`
+ Title string `json:"title,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ ToolCallID string `json:"toolCallId,omitempty"`
+ ToolInput json.RawMessage `json:"rawInput,omitempty"`
+ ToolResult json.RawMessage `json:"rawOutput,omitempty"`
+ Meta map[string]any `json:"_meta,omitempty"`
+}
+
+type hookAgentPermissionPayload struct {
+ RequestID string `json:"request_id"`
+ Decision string `json:"decision,omitempty"`
+ ToolInput json.RawMessage `json:"tool_input,omitempty"`
+ Options []hookspkg.PermissionOption `json:"options,omitempty"`
+ ToolCall hookspkg.PermissionToolCall `json:"tool_call"`
+}
+
+const hookPermissionDecisionDenied = "denied"
+
+func dispatchACPAgentHookEvent(
+ ctx context.Context,
+ logger *slog.Logger,
+ hooks hookRuntime,
+ sessionCtx hookspkg.SessionContext,
+ event any,
+ timestamp time.Time,
+) {
+ if hooks == nil {
+ return
+ }
+ agentEvent, ok := normalizeHookAgentEvent(event)
+ if !ok {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if timestamp.IsZero() {
+ timestamp = time.Now().UTC()
+ }
+ if sessionCtx.SessionID == "" {
+ sessionCtx.SessionID = strings.TrimSpace(agentEvent.SessionID)
+ }
+
+ switch agentEvent.Type {
+ case acp.EventTypeToolCall, acp.EventTypeToolResult:
+ dispatchToolHookEvent(ctx, logger, hooks, sessionCtx, agentEvent, timestamp)
+ case acp.EventTypePermission:
+ dispatchPermissionHookEvent(ctx, logger, hooks, sessionCtx, agentEvent, timestamp)
+ }
+}
+
+func dispatchToolHookEvent(
+ ctx context.Context,
+ logger *slog.Logger,
+ hooks hookRuntime,
+ sessionCtx hookspkg.SessionContext,
+ event acp.AgentEvent,
+ defaultTimestamp time.Time,
+) {
+ raw, ok := decodeHookAgentToolPayload(event.Raw)
+ if !ok {
+ return
+ }
+ base := hookspkg.PayloadBase{Timestamp: hookEventTimestamp(event.Timestamp, defaultTimestamp)}
+ turn := hookspkg.TurnContext{TurnID: strings.TrimSpace(event.TurnID)}
+ ref := hookspkg.ToolCallRef{
+ ToolCallID: firstNonEmpty(strings.TrimSpace(event.ToolCallID), strings.TrimSpace(raw.ToolCallID)),
+ ToolName: hookAgentToolName(raw, strings.TrimSpace(event.Title)),
+ ToolNamespace: "",
+ ReadOnly: strings.EqualFold(strings.TrimSpace(raw.Kind), "read"),
+ }
+
+ updateType := strings.ToLower(strings.TrimSpace(raw.SessionUpdate))
+ status := strings.ToLower(strings.TrimSpace(raw.Status))
+ switch {
+ case updateType == "tool_call":
+ _, err := hooks.DispatchToolPreCall(ctx, hookspkg.ToolPreCallPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookToolPreCall),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ ToolCallRef: ref,
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookToolPreCall, err)
+ case updateType == "tool_call_update" && status == "completed":
+ _, err := hooks.DispatchToolPostCall(ctx, hookspkg.ToolPostCallPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookToolPostCall),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ ToolCallRef: ref,
+ Title: firstNonEmpty(strings.TrimSpace(event.Title), strings.TrimSpace(raw.Title)),
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ ToolResult: acp.CloneRawMessage(raw.ToolResult),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookToolPostCall, err)
+ case updateType == "tool_call_update" && status == "failed":
+ _, err := hooks.DispatchToolPostError(ctx, hookspkg.ToolPostErrorPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookToolPostError),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ ToolCallRef: ref,
+ Title: firstNonEmpty(strings.TrimSpace(event.Title), strings.TrimSpace(raw.Title)),
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ Error: firstNonEmpty(strings.TrimSpace(event.Error), strings.TrimSpace(string(raw.ToolResult))),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookToolPostError, err)
+ }
+}
+
+func dispatchPermissionHookEvent(
+ ctx context.Context,
+ logger *slog.Logger,
+ hooks hookRuntime,
+ sessionCtx hookspkg.SessionContext,
+ event acp.AgentEvent,
+ defaultTimestamp time.Time,
+) {
+ raw, ok := decodeHookAgentPermissionPayload(event.Raw)
+ if !ok {
+ return
+ }
+ base := hookspkg.PayloadBase{Timestamp: hookEventTimestamp(event.Timestamp, defaultTimestamp)}
+ turn := hookspkg.TurnContext{TurnID: strings.TrimSpace(event.TurnID)}
+ decision := firstNonEmpty(strings.TrimSpace(event.Decision), strings.TrimSpace(raw.Decision))
+ decisionClass := hookPermissionDecisionClass(decision)
+
+ switch {
+ case decision == "":
+ _, err := hooks.DispatchPermissionRequest(ctx, hookspkg.PermissionRequestPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookPermissionRequest),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ RequestID: firstNonEmpty(strings.TrimSpace(event.RequestID), strings.TrimSpace(raw.RequestID)),
+ Action: strings.TrimSpace(event.Action),
+ Resource: strings.TrimSpace(event.Resource),
+ DecisionClass: decisionClass,
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ ToolCall: clonePermissionToolCall(raw.ToolCall),
+ Options: clonePermissionOptions(raw.Options),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookPermissionRequest, err)
+ case hookPermissionDenied(decision):
+ _, err := hooks.DispatchPermissionDenied(ctx, hookspkg.PermissionDeniedPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookPermissionDenied),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ RequestID: firstNonEmpty(strings.TrimSpace(event.RequestID), strings.TrimSpace(raw.RequestID)),
+ Action: strings.TrimSpace(event.Action),
+ Resource: strings.TrimSpace(event.Resource),
+ Decision: decision,
+ DecisionClass: decisionClass,
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ ToolCall: clonePermissionToolCall(raw.ToolCall),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookPermissionDenied, err)
+ default:
+ _, err := hooks.DispatchPermissionResolved(ctx, hookspkg.PermissionResolvedPayload{
+ PayloadBase: withHookEvent(base, hookspkg.HookPermissionResolved),
+ SessionContext: sessionCtx,
+ TurnContext: turn,
+ RequestID: firstNonEmpty(strings.TrimSpace(event.RequestID), strings.TrimSpace(raw.RequestID)),
+ Action: strings.TrimSpace(event.Action),
+ Resource: strings.TrimSpace(event.Resource),
+ Decision: decision,
+ DecisionClass: decisionClass,
+ ToolInput: acp.CloneRawMessage(raw.ToolInput),
+ ToolCall: clonePermissionToolCall(raw.ToolCall),
+ })
+ warnHookAgentDispatch(ctx, logger, hookspkg.HookPermissionResolved, err)
+ }
+}
+
+func normalizeHookAgentEvent(event any) (acp.AgentEvent, bool) {
+ switch typed := event.(type) {
+ case acp.AgentEvent:
+ return typed, true
+ case *acp.AgentEvent:
+ if typed == nil {
+ return acp.AgentEvent{}, false
+ }
+ return *typed, true
+ default:
+ return acp.AgentEvent{}, false
+ }
+}
+
+func decodeHookAgentToolPayload(raw json.RawMessage) (hookAgentToolPayload, bool) {
+ if len(raw) == 0 {
+ return hookAgentToolPayload{}, false
+ }
+ var payload hookAgentToolPayload
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return hookAgentToolPayload{}, false
+ }
+ return payload, true
+}
+
+func decodeHookAgentPermissionPayload(raw json.RawMessage) (hookAgentPermissionPayload, bool) {
+ if len(raw) == 0 {
+ return hookAgentPermissionPayload{}, false
+ }
+ var payload hookAgentPermissionPayload
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return hookAgentPermissionPayload{}, false
+ }
+ return payload, true
+}
+
+func withHookEvent(base hookspkg.PayloadBase, event hookspkg.HookEvent) hookspkg.PayloadBase {
+ base.Event = event
+ return base
+}
+
+func hookEventTimestamp(eventTimestamp time.Time, fallback time.Time) time.Time {
+ if !eventTimestamp.IsZero() {
+ return eventTimestamp
+ }
+ if !fallback.IsZero() {
+ return fallback
+ }
+ return time.Now().UTC()
+}
+
+func hookAgentToolName(payload hookAgentToolPayload, fallback string) string {
+ if len(payload.Meta) > 0 {
+ for _, value := range payload.Meta {
+ nested, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ if toolName := strings.TrimSpace(stringMapValue(nested, "toolName")); toolName != "" {
+ return toolName
+ }
+ }
+ }
+ return firstNonEmpty(strings.TrimSpace(payload.Title), strings.TrimSpace(payload.Kind), fallback)
+}
+
+func hookPermissionDecisionClass(decision string) string {
+ if decision == "" {
+ return "interactive"
+ }
+ if hookPermissionDenied(decision) {
+ return hookPermissionDecisionDenied
+ }
+ return "resolved"
+}
+
+func hookPermissionDenied(decision string) bool {
+ clean := strings.ToLower(strings.TrimSpace(decision))
+ switch {
+ case clean == "":
+ return false
+ case clean == "block", clean == "blocked":
+ return true
+ case clean == "deny", clean == hookPermissionDecisionDenied, clean == "reject", clean == "rejected":
+ return true
+ case strings.HasPrefix(clean, "block-"):
+ return true
+ case strings.HasPrefix(clean, "deny-"):
+ return true
+ case strings.HasPrefix(clean, "reject-"):
+ return true
+ default:
+ return false
+ }
+}
+
+func clonePermissionToolCall(src hookspkg.PermissionToolCall) hookspkg.PermissionToolCall {
+ cloned := src
+ if len(src.Locations) > 0 {
+ cloned.Locations = append([]hookspkg.ToolLocation(nil), src.Locations...)
+ }
+ return cloned
+}
+
+func clonePermissionOptions(src []hookspkg.PermissionOption) []hookspkg.PermissionOption {
+ if len(src) == 0 {
+ return nil
+ }
+ cloned := make([]hookspkg.PermissionOption, 0, len(src))
+ cloned = append(cloned, src...)
+ return cloned
+}
+
+func warnHookAgentDispatch(ctx context.Context, logger *slog.Logger, event hookspkg.HookEvent, err error) {
+ if err == nil {
+ return
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ logger.WarnContext(ctx, "daemon: hook agent-event dispatch failed", "hook_event", event.String(), "error", err)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
+
+func stringMapValue(values map[string]any, key string) string {
+ if values == nil {
+ return ""
+ }
+ raw, ok := values[key]
+ if !ok {
+ return ""
+ }
+ typed, ok := raw.(string)
+ if !ok {
+ return ""
+ }
+ return typed
+}
diff --git a/internal/daemon/hook_binding_resources.go b/internal/daemon/hook_binding_resources.go
new file mode 100644
index 000000000..f3d8720ba
--- /dev/null
+++ b/internal/daemon/hook_binding_resources.go
@@ -0,0 +1,291 @@
+package daemon
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ hookBindingResourceKind resources.ResourceKind = "hook.binding"
+ hookBindingResourceMaxBytes = 256 << 10
+)
+
+type hookBindingProjectionPlan struct {
+ revision int64
+ operations int
+ state *hookspkg.BindingState
+}
+
+func (p *hookBindingProjectionPlan) Kind() resources.ResourceKind {
+ return hookBindingResourceKind
+}
+
+func (p *hookBindingProjectionPlan) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *hookBindingProjectionPlan) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+type hookBindingProjector struct {
+ runtime *hookspkg.Hooks
+}
+
+type hookBindingCodec struct {
+ inner resources.KindCodec[hookBindingCodecSpec]
+}
+
+type hookBindingCodecSpec struct {
+ Name string `json:"name"`
+ Event hookspkg.HookEvent `json:"event"`
+ Source hookspkg.HookSource `json:"source"`
+ Mode hookspkg.HookMode `json:"mode,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ PrioritySet bool `json:"priority_set,omitempty"`
+ Timeout time.Duration `json:"timeout,omitempty"`
+ Matcher hookspkg.HookMatcher `json:"matcher"`
+ ExecutorKind hookspkg.HookExecutorKind `json:"executor_kind,omitempty"`
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ WorkingDir string `json:"working_dir,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ SkillSource hookspkg.HookSkillSource `json:"skill_source,omitempty"`
+}
+
+var _ resources.TypedProjector[hookspkg.HookDecl] = (*hookBindingProjector)(nil)
+var _ resources.KindCodec[hookspkg.HookDecl] = (*hookBindingCodec)(nil)
+
+func newHookBindingCodec() (resources.KindCodec[hookspkg.HookDecl], error) {
+ inner, err := resources.NewJSONCodec(
+ hookBindingResourceKind,
+ hookBindingResourceMaxBytes,
+ func(
+ ctx context.Context,
+ scope resources.ResourceScope,
+ spec hookBindingCodecSpec,
+ ) (hookBindingCodecSpec, error) {
+ validated, err := validateHookBindingSpec(ctx, scope, spec.hookDecl())
+ if err != nil {
+ return hookBindingCodecSpec{}, err
+ }
+ return newHookBindingCodecSpec(validated), nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &hookBindingCodec{inner: inner}, nil
+}
+
+func (c *hookBindingCodec) Kind() resources.ResourceKind {
+ if c == nil || c.inner == nil {
+ return hookBindingResourceKind
+ }
+ return c.inner.Kind()
+}
+
+func (c *hookBindingCodec) MaxBytes() int {
+ if c == nil || c.inner == nil {
+ return hookBindingResourceMaxBytes
+ }
+ return c.inner.MaxBytes()
+}
+
+func (c *hookBindingCodec) Encode(spec hookspkg.HookDecl) ([]byte, error) {
+ if c == nil || c.inner == nil {
+ return nil, errors.New("daemon: hook binding codec is required")
+ }
+ return c.inner.Encode(newHookBindingCodecSpec(spec))
+}
+
+func (c *hookBindingCodec) DecodeAndValidate(
+ ctx context.Context,
+ scope resources.ResourceScope,
+ raw []byte,
+) (hookspkg.HookDecl, error) {
+ if c == nil || c.inner == nil {
+ return hookspkg.HookDecl{}, errors.New("daemon: hook binding codec is required")
+ }
+
+ spec, err := c.inner.DecodeAndValidate(ctx, scope, raw)
+ if err != nil {
+ return hookspkg.HookDecl{}, err
+ }
+ return spec.hookDecl(), nil
+}
+
+func (c *hookBindingCodec) ValidateAndCanonicalizeRaw(
+ ctx context.Context,
+ scope resources.ResourceScope,
+ raw []byte,
+) ([]byte, error) {
+ if c == nil || c.inner == nil {
+ return nil, errors.New("daemon: hook binding codec is required")
+ }
+
+ rawCodec, ok := c.inner.(interface {
+ ValidateAndCanonicalizeRaw(context.Context, resources.ResourceScope, []byte) ([]byte, error)
+ })
+ if !ok {
+ return nil, errors.New("daemon: hook binding codec does not support raw validation")
+ }
+ return rawCodec.ValidateAndCanonicalizeRaw(ctx, scope, raw)
+}
+
+func newHookBindingCodecSpec(decl hookspkg.HookDecl) hookBindingCodecSpec {
+ cloned := cloneDaemonHookDecl(decl)
+ return hookBindingCodecSpec{
+ Name: cloned.Name,
+ Event: cloned.Event,
+ Source: cloned.Source,
+ Mode: cloned.Mode,
+ Required: cloned.Required,
+ Priority: cloned.Priority,
+ PrioritySet: cloned.PrioritySet,
+ Timeout: cloned.Timeout,
+ Matcher: cloned.Matcher,
+ ExecutorKind: cloned.ExecutorKind,
+ Command: cloned.Command,
+ Args: cloned.Args,
+ WorkingDir: cloned.WorkingDir,
+ Env: cloned.Env,
+ Metadata: cloned.Metadata,
+ SkillSource: cloned.SkillSource,
+ }
+}
+
+func (s hookBindingCodecSpec) hookDecl() hookspkg.HookDecl {
+ return cloneDaemonHookDecl(hookspkg.HookDecl{
+ Name: s.Name,
+ Event: s.Event,
+ Source: s.Source,
+ Mode: s.Mode,
+ Required: s.Required,
+ Priority: s.Priority,
+ PrioritySet: s.PrioritySet,
+ Timeout: s.Timeout,
+ Matcher: s.Matcher,
+ ExecutorKind: s.ExecutorKind,
+ Command: s.Command,
+ Args: s.Args,
+ WorkingDir: s.WorkingDir,
+ Env: s.Env,
+ Metadata: s.Metadata,
+ SkillSource: s.SkillSource,
+ })
+}
+
+func newHookBindingStore(
+ raw resources.RawStore,
+ codec resources.KindCodec[hookspkg.HookDecl],
+) (resources.Store[hookspkg.HookDecl], error) {
+ return resources.NewStore(raw, codec)
+}
+
+func newHookBindingProjector(runtime *hookspkg.Hooks) resources.TypedProjector[hookspkg.HookDecl] {
+ if runtime == nil {
+ return nil
+ }
+ return &hookBindingProjector{runtime: runtime}
+}
+
+func validateHookBindingSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec hookspkg.HookDecl,
+) (hookspkg.HookDecl, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return hookspkg.HookDecl{}, err
+ }
+
+ normalized, err := hookspkg.CanonicalizeHookDecl(spec)
+ if err != nil {
+ return hookspkg.HookDecl{}, err
+ }
+
+ if normalizedScope.Kind == resources.ResourceScopeKindWorkspace {
+ workspaceID := strings.TrimSpace(normalized.Matcher.WorkspaceID)
+ switch {
+ case workspaceID == "":
+ normalized.Matcher.WorkspaceID = normalizedScope.ID
+ case workspaceID != normalizedScope.ID:
+ return hookspkg.HookDecl{}, fmt.Errorf(
+ "%w: hook workspace matcher %q does not match resource scope %q",
+ resources.ErrInvalidScopeBinding,
+ workspaceID,
+ normalizedScope.ID,
+ )
+ }
+ }
+
+ return normalized, nil
+}
+
+func (p *hookBindingProjector) Kind() resources.ResourceKind {
+ return hookBindingResourceKind
+}
+
+func (p *hookBindingProjector) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *hookBindingProjector) Build(
+ _ context.Context,
+ records []resources.Record[hookspkg.HookDecl],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.runtime == nil {
+ return nil, errors.New("daemon: hook binding projector runtime is required")
+ }
+
+ decls := make([]hookspkg.HookDecl, 0, len(records))
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ decls = append(decls, cloneDaemonHookDecl(record.Spec))
+ }
+
+ state, err := p.runtime.BuildBindingState(decls)
+ if err != nil {
+ return nil, err
+ }
+
+ return &hookBindingProjectionPlan{
+ revision: revision,
+ operations: state.HookCount(),
+ state: state,
+ }, nil
+}
+
+func (p *hookBindingProjector) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.runtime == nil {
+ return errors.New("daemon: hook binding projector runtime is required")
+ }
+ if ctx == nil {
+ return errors.New("daemon: hook binding projector apply context is required")
+ }
+
+ typed, ok := plan.(*hookBindingProjectionPlan)
+ if !ok {
+ return fmt.Errorf("daemon: hook binding projector plan has type %T", plan)
+ }
+
+ return p.runtime.ApplyBindingState(typed.state, typed.revision)
+}
diff --git a/internal/daemon/hook_binding_resources_integration_test.go b/internal/daemon/hook_binding_resources_integration_test.go
new file mode 100644
index 000000000..27def75b2
--- /dev/null
+++ b/internal/daemon/hook_binding_resources_integration_test.go
@@ -0,0 +1,393 @@
+//go:build integration
+
+package daemon
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/acp"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/session"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+type hookBindingIntegrationHarness struct {
+ store resources.Store[hookspkg.HookDecl]
+ driver resources.ReconcileDriver
+ hooks *hookspkg.Hooks
+ notifier *hooksNotifier
+ actor resources.MutationActor
+}
+
+func TestHookBindingResourceReconcileFiresToolHookThroughSessionNotifier(t *testing.T) {
+ toolPayloads := make(chan hookspkg.ToolPreCallPayload, 1)
+ h := newHookBindingIntegrationHarness(t, map[string]hookspkg.Executor{
+ "tool-hook": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ select {
+ case toolPayloads <- payload:
+ default:
+ }
+ return hookspkg.ToolCallPatch{}, nil
+ },
+ ),
+ })
+
+ record := h.putBinding(t, "tool-hook", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-hook",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if record.Version <= 0 {
+ t.Fatalf("record.Version = %d, want positive", record.Version)
+ }
+ if err := h.driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.RunBoot() error = %v", err)
+ }
+
+ h.notifier.OnAgentEventForSession(testutil.Context(t), integrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+
+ select {
+ case payload := <-toolPayloads:
+ if payload.SessionID != "sess-1" || payload.WorkspaceID != "ws-1" {
+ t.Fatalf("payload.SessionContext = %#v, want session metadata", payload.SessionContext)
+ }
+ if payload.ToolName != "Read" {
+ t.Fatalf("payload.ToolName = %q, want %q", payload.ToolName, "Read")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for resource-backed tool.pre_call hook")
+ }
+}
+
+func TestHookBindingResourceReconcileFiresPermissionHooksThroughSessionNotifier(t *testing.T) {
+ requests := make(chan hookspkg.PermissionRequestPayload, 1)
+ resolved := make(chan hookspkg.PermissionResolvedPayload, 1)
+ denied := make(chan hookspkg.PermissionDeniedPayload, 1)
+
+ h := newHookBindingIntegrationHarness(t, map[string]hookspkg.Executor{
+ "perm-request": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.PermissionRequestPayload) (hookspkg.PermissionRequestPatch, error) {
+ requests <- payload
+ return hookspkg.PermissionRequestPatch{}, nil
+ },
+ ),
+ "perm-resolved": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.PermissionResolvedPayload) (hookspkg.PermissionResolvedPatch, error) {
+ resolved <- payload
+ return hookspkg.PermissionResolvedPatch{}, nil
+ },
+ ),
+ "perm-denied": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.PermissionDeniedPayload) (hookspkg.PermissionDeniedPatch, error) {
+ denied <- payload
+ return hookspkg.PermissionDeniedPatch{}, nil
+ },
+ ),
+ })
+
+ h.putBinding(t, "perm-request", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "perm-request",
+ Event: hookspkg.HookPermissionRequest,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ h.putBinding(t, "perm-resolved", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "perm-resolved",
+ Event: hookspkg.HookPermissionResolved,
+ Source: hookspkg.HookSourceNative,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ h.putBinding(t, "perm-denied", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "perm-denied",
+ Event: hookspkg.HookPermissionDenied,
+ Source: hookspkg.HookSourceNative,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.RunBoot() error = %v", err)
+ }
+
+ sessionValue := integrationSession()
+ h.notifier.OnAgentEventForSession(testutil.Context(t), sessionValue, acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "", "Read")),
+ })
+ h.notifier.OnAgentEventForSession(testutil.Context(t), sessionValue, acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Decision: "allow",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "allow", "Read")),
+ })
+ h.notifier.OnAgentEventForSession(testutil.Context(t), sessionValue, acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Decision: "deny",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "deny", "Read")),
+ })
+
+ select {
+ case payload := <-requests:
+ if payload.SessionID != "sess-1" || payload.ToolCall.Kind != "Read" {
+ t.Fatalf("permission.request payload = %#v, want sess-1 and Read tool", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for permission.request hook")
+ }
+
+ select {
+ case payload := <-resolved:
+ if payload.Decision != "allow" || payload.DecisionClass != "resolved" {
+ t.Fatalf("permission.resolved payload = %#v, want allow/resolved", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for permission.resolved hook")
+ }
+
+ select {
+ case payload := <-denied:
+ if payload.Decision != "deny" || payload.DecisionClass != "denied" {
+ t.Fatalf("permission.denied payload = %#v, want deny/denied", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for permission.denied hook")
+ }
+}
+
+func TestHookBindingResourceReconcileFailurePreservesAppliedRuntimeState(t *testing.T) {
+ toolPayloads := make(chan hookspkg.ToolPreCallPayload, 2)
+
+ h := newHookBindingIntegrationHarness(t, map[string]hookspkg.Executor{
+ "tool-stable": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ toolPayloads <- payload
+ return hookspkg.ToolCallPatch{}, nil
+ },
+ ),
+ })
+
+ record := h.putBinding(t, "tool-hook", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-stable",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("initial driver.RunBoot() error = %v", err)
+ }
+ h.notifier.OnAgentEventForSession(testutil.Context(t), integrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+ select {
+ case <-toolPayloads:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for initial stable hook dispatch")
+ }
+
+ _ = h.putBinding(t, "tool-hook", record.Version, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-missing",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err == nil {
+ t.Fatal("driver.RunBoot() error = nil, want missing executor failure")
+ }
+
+ h.notifier.OnAgentEventForSession(testutil.Context(t), integrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+ select {
+ case payload := <-toolPayloads:
+ if payload.ToolName != "Read" || payload.SessionID != "sess-1" {
+ t.Fatalf("post-failure payload = %#v, want stable hook payload", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for preserved stable hook after projector failure")
+ }
+}
+
+func newHookBindingIntegrationHarness(
+ t *testing.T,
+ nativeExecutors map[string]hookspkg.Executor,
+) *hookBindingIntegrationHarness {
+ t.Helper()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := newHookBindingCodec()
+ if err != nil {
+ t.Fatalf("newHookBindingCodec() error = %v", err)
+ }
+ store, err := newHookBindingStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("newHookBindingStore() error = %v", err)
+ }
+ hooks := hookspkg.NewHooks(
+ hookspkg.WithLogger(discardLogger()),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(nativeExecutors)),
+ )
+ t.Cleanup(hooks.Close)
+
+ registration, err := resources.NewTypedProjectorRegistration(codec, newHookBindingProjector(hooks))
+ if err != nil {
+ t.Fatalf("NewTypedProjectorRegistration() error = %v", err)
+ }
+ driver, err := resources.NewReconcileDriver(
+ kernel,
+ resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "integration-control",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "integration"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ []resources.ProjectorRegistration{registration},
+ resources.WithReconcileLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := driver.Close(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.Close() error = %v", err)
+ }
+ })
+
+ notifier := newHooksNotifier(discardLogger(), func() time.Time {
+ return time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ })
+ notifier.setRuntime(hooks, nil)
+
+ return &hookBindingIntegrationHarness{
+ store: store,
+ driver: driver,
+ hooks: hooks,
+ notifier: notifier,
+ actor: resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "integration-writer",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "integration"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ }
+}
+
+func (h *hookBindingIntegrationHarness) putBinding(
+ t *testing.T,
+ id string,
+ expectedVersion int64,
+ scope resources.ResourceScope,
+ decl hookspkg.HookDecl,
+) resources.Record[hookspkg.HookDecl] {
+ t.Helper()
+
+ spec, err := validateHookBindingSpec(testutil.Context(t), scope, decl)
+ if err != nil {
+ t.Fatalf("validateHookBindingSpec() error = %v", err)
+ }
+ record, err := h.store.Put(testutil.Context(t), h.actor, resources.Draft[hookspkg.HookDecl]{
+ ID: id,
+ Scope: scope,
+ ExpectedVersion: expectedVersion,
+ Spec: spec,
+ })
+ if err != nil {
+ t.Fatalf("store.Put(%q) error = %v", id, err)
+ }
+ return record
+}
+
+func integrationSession() *session.Session {
+ now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ return &session.Session{
+ ID: "sess-1",
+ Name: "demo",
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ Workspace: "/tmp/ws-1",
+ Type: session.SessionTypeUser,
+ State: session.StateActive,
+ CreatedAt: now.Add(-time.Minute),
+ UpdatedAt: now,
+ }
+}
diff --git a/internal/daemon/hook_binding_resources_test.go b/internal/daemon/hook_binding_resources_test.go
new file mode 100644
index 000000000..aea38d31e
--- /dev/null
+++ b/internal/daemon/hook_binding_resources_test.go
@@ -0,0 +1,985 @@
+package daemon
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/acp"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/session"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestDispatchACPAgentHookEventDispatchesToolAndPermissionFamilies(t *testing.T) {
+ t.Parallel()
+
+ sessionCtx := hookspkg.SessionContext{
+ SessionID: "sess-1",
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ Workspace: "/tmp/ws-1",
+ }
+ fixedNow := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ var got []string
+
+ runtime := &fakeHookRuntime{
+ onToolPreCall: func(_ context.Context, payload hookspkg.ToolPreCallPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.SessionID != sessionCtx.SessionID || payload.WorkspaceID != sessionCtx.WorkspaceID {
+ t.Fatalf("tool.pre_call session context = %#v, want %#v", payload.SessionContext, sessionCtx)
+ }
+ if payload.ToolName != "Read" {
+ t.Fatalf("tool.pre_call ToolName = %q, want %q", payload.ToolName, "Read")
+ }
+ return nil
+ },
+ onToolPostCall: func(_ context.Context, payload hookspkg.ToolPostCallPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.ToolName != "Read" || string(payload.ToolResult) != `{"ok":true}` {
+ t.Fatalf("tool.post_call payload = %#v, want Read with result", payload)
+ }
+ return nil
+ },
+ onToolPostError: func(_ context.Context, payload hookspkg.ToolPostErrorPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.ToolName != "Read" || payload.Error != "boom" {
+ t.Fatalf("tool.post_error payload = %#v, want Read with boom", payload)
+ }
+ return nil
+ },
+ onPermRequest: func(_ context.Context, payload hookspkg.PermissionRequestPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.SessionID != sessionCtx.SessionID || payload.ToolCall.Kind != "Read" {
+ t.Fatalf("permission.request payload = %#v, want session context and Read tool", payload)
+ }
+ return nil
+ },
+ onPermResolved: func(_ context.Context, payload hookspkg.PermissionResolvedPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.Decision != "allow" || payload.DecisionClass != "resolved" {
+ t.Fatalf("permission.resolved payload = %#v, want allow/resolved", payload)
+ }
+ return nil
+ },
+ onPermDenied: func(_ context.Context, payload hookspkg.PermissionDeniedPayload) error {
+ got = append(got, string(payload.Event))
+ if payload.Decision != "deny" || payload.DecisionClass != "denied" {
+ t.Fatalf("permission.denied payload = %#v, want deny/denied", payload)
+ }
+ return nil
+ },
+ }
+
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ },
+ fixedNow,
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypeToolResult,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call_update", "completed", map[string]any{"ok": true})),
+ },
+ fixedNow,
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypeToolResult,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Error: "boom",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call_update", "failed", "boom")),
+ },
+ fixedNow,
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "", "Read")),
+ },
+ fixedNow,
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Decision: "allow",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "allow", "Read")),
+ },
+ fixedNow,
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ sessionCtx,
+ acp.AgentEvent{
+ Type: acp.EventTypePermission,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Decision: "deny",
+ Raw: mustMarshalJSON(t, permissionEventRaw("perm-1", "deny", "Read")),
+ },
+ fixedNow,
+ )
+
+ want := []string{
+ string(hookspkg.HookToolPreCall),
+ string(hookspkg.HookToolPostCall),
+ string(hookspkg.HookToolPostError),
+ string(hookspkg.HookPermissionRequest),
+ string(hookspkg.HookPermissionResolved),
+ string(hookspkg.HookPermissionDenied),
+ }
+ if !testutil.EqualStringSlices(got, want) {
+ t.Fatalf("dispatchACPAgentHookEvent() order = %#v, want %#v", got, want)
+ }
+}
+
+func TestDispatchACPAgentHookEventDefaultsAndIgnoresUnsupportedInputs(t *testing.T) {
+ t.Parallel()
+
+ got := make(chan hookspkg.ToolPreCallPayload, 1)
+ runtime := &fakeHookRuntime{
+ onToolPreCall: func(_ context.Context, payload hookspkg.ToolPreCallPayload) error {
+ got <- payload
+ return nil
+ },
+ }
+
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ nil,
+ runtime,
+ hookspkg.SessionContext{},
+ &acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-default",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ },
+ time.Time{},
+ )
+
+ select {
+ case payload := <-got:
+ if payload.SessionID != "acp-session-default" {
+ t.Fatalf("payload.SessionID = %q, want %q", payload.SessionID, "acp-session-default")
+ }
+ if payload.Timestamp.IsZero() {
+ t.Fatal("payload.Timestamp is zero, want default timestamp")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for defaulted tool hook dispatch")
+ }
+
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ runtime,
+ hookspkg.SessionContext{SessionID: "sess-1"},
+ "not-an-agent-event",
+ time.Now().UTC(),
+ )
+ dispatchACPAgentHookEvent(
+ testutil.Context(t),
+ discardLogger(),
+ nil,
+ hookspkg.SessionContext{SessionID: "sess-1"},
+ acp.AgentEvent{Type: acp.EventTypeToolCall},
+ time.Now().UTC(),
+ )
+}
+
+func TestHookAgentEventHelpersHandlePointerAndAliasInputs(t *testing.T) {
+ t.Parallel()
+
+ event, ok := normalizeHookAgentEvent(&acp.AgentEvent{Type: acp.EventTypePermission, RequestID: "perm-1"})
+ if !ok {
+ t.Fatal("normalizeHookAgentEvent(pointer) ok = false, want true")
+ }
+ if event.RequestID != "perm-1" {
+ t.Fatalf("normalizeHookAgentEvent(pointer).RequestID = %q, want %q", event.RequestID, "perm-1")
+ }
+ if _, ok := normalizeHookAgentEvent(struct{}{}); ok {
+ t.Fatal("normalizeHookAgentEvent(struct{}) ok = true, want false")
+ }
+
+ for _, decision := range []string{"deny", "blocked", "reject-later"} {
+ if !hookPermissionDenied(decision) {
+ t.Fatalf("hookPermissionDenied(%q) = false, want true", decision)
+ }
+ }
+ if hookPermissionDenied("allow") {
+ t.Fatal(`hookPermissionDenied("allow") = true, want false`)
+ }
+ if hookPermissionDenied("") {
+ t.Fatal(`hookPermissionDenied("") = true, want false`)
+ }
+ if !hookPermissionDenied("block-once") {
+ t.Fatal(`hookPermissionDenied("block-once") = false, want true`)
+ }
+
+ if got := clonePermissionOptions(nil); got != nil {
+ t.Fatalf("clonePermissionOptions(nil) = %#v, want nil", got)
+ }
+ options := []hookspkg.PermissionOption{{Label: "Allow", Decision: "allow"}}
+ cloned := clonePermissionOptions(options)
+ if len(cloned) != 1 || cloned[0].Label != "Allow" {
+ t.Fatalf("clonePermissionOptions() = %#v, want cloned Allow option", cloned)
+ }
+ options[0].Label = "Mutated"
+ if cloned[0].Label != "Allow" {
+ t.Fatalf("cloned option label = %q, want %q", cloned[0].Label, "Allow")
+ }
+
+ warnHookAgentDispatch(testutil.Context(t), nil, hookspkg.HookToolPreCall, nil)
+ warnHookAgentDispatch(context.Background(), nil, hookspkg.HookToolPreCall, context.DeadlineExceeded)
+
+ eventTimestamp := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ if got := hookEventTimestamp(eventTimestamp, time.Time{}); !got.Equal(eventTimestamp) {
+ t.Fatalf("hookEventTimestamp(event, zero) = %v, want %v", got, eventTimestamp)
+ }
+ fallbackTimestamp := time.Date(2026, 4, 15, 12, 1, 0, 0, time.UTC)
+ if got := hookEventTimestamp(time.Time{}, fallbackTimestamp); !got.Equal(fallbackTimestamp) {
+ t.Fatalf("hookEventTimestamp(zero, fallback) = %v, want %v", got, fallbackTimestamp)
+ }
+}
+
+func TestNewHookBindingPublisherUsesResourceBackedSync(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := newHookBindingCodec()
+ if err != nil {
+ t.Fatalf("newHookBindingCodec() error = %v", err)
+ }
+ codecs := resources.NewCodecRegistry()
+ if err := resources.RegisterCodec(codecs, codec); err != nil {
+ t.Fatalf("RegisterCodec() error = %v", err)
+ }
+ store, err := newHookBindingStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("newHookBindingStore() error = %v", err)
+ }
+
+ homePaths := testHomePaths(t)
+ d := newTestDaemon(t, homePaths, testConfigPtr(t, homePaths))
+ runtime := hookspkg.NewHooks(hookspkg.WithLogger(discardLogger()))
+ t.Cleanup(runtime.Close)
+
+ publisher, err := d.newHookBindingPublisher(&bootState{
+ logger: discardLogger(),
+ resourceKernel: kernel,
+ resourceCodecs: codecs,
+ }, runtime, []hookBindingDeclarationProvider{
+ func(context.Context) ([]hookspkg.HookDecl, error) {
+ return []hookspkg.HookDecl{{
+ Name: "tool-hook",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ Command: "/bin/true",
+ Matcher: hookspkg.HookMatcher{ToolName: "Read"},
+ }}, nil
+ },
+ })
+ if err != nil {
+ t.Fatalf("newHookBindingPublisher() error = %v", err)
+ }
+ if err := publisher.Sync(testutil.Context(t)); err != nil {
+ t.Fatalf("publisher.Sync() error = %v", err)
+ }
+
+ records, err := store.List(testutil.Context(t), resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "reader",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "reader"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }, resources.ResourceFilter{})
+ if err != nil {
+ t.Fatalf("store.List() error = %v", err)
+ }
+ if len(records) != 1 {
+ t.Fatalf("store.List() count = %d, want 1", len(records))
+ }
+ if got := records[0].Spec.Name; got != "tool-hook" {
+ t.Fatalf("record.Spec.Name = %q, want %q", got, "tool-hook")
+ }
+}
+
+func TestNewHookBindingPublisherRequiresRegisteredHookCodec(t *testing.T) {
+ t.Parallel()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+
+ homePaths := testHomePaths(t)
+ d := newTestDaemon(t, homePaths, testConfigPtr(t, homePaths))
+ runtime := hookspkg.NewHooks(hookspkg.WithLogger(discardLogger()))
+ t.Cleanup(runtime.Close)
+
+ _, err = d.newHookBindingPublisher(&bootState{
+ logger: discardLogger(),
+ resourceKernel: kernel,
+ resourceCodecs: resources.NewCodecRegistry(),
+ }, runtime, nil)
+ if err == nil {
+ t.Fatal("newHookBindingPublisher() error = nil, want missing codec failure")
+ }
+}
+
+func TestHookBindingProjectorBuildDoesNotMutateLiveRuntimeAndApplySwapsAtomically(t *testing.T) {
+ t.Parallel()
+
+ runtime := hookspkg.NewHooks(
+ hookspkg.WithLogger(discardLogger()),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(map[string]hookspkg.Executor{
+ "tool-alpha": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, _ hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ name := "alpha"
+ return hookspkg.ToolCallPatch{ToolName: &name}, nil
+ },
+ ),
+ "tool-beta": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, _ hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ name := "beta"
+ return hookspkg.ToolCallPatch{ToolName: &name}, nil
+ },
+ ),
+ })),
+ )
+ t.Cleanup(runtime.Close)
+
+ projector := newHookBindingProjector(runtime)
+ ctx := testutil.Context(t)
+
+ planAlpha, err := projector.Build(ctx, []resources.Record[hookspkg.HookDecl]{
+ testHookBindingRecord(t, 1, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-alpha",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ }),
+ })
+ if err != nil {
+ t.Fatalf("projector.Build(alpha) error = %v", err)
+ }
+ if got, want := runtime.Version(), int64(0); got != want {
+ t.Fatalf("runtime.Version() before apply = %d, want %d", got, want)
+ }
+ if got, want := dispatchProjectedToolName(t, runtime), "Read"; got != want {
+ t.Fatalf("DispatchToolPreCall() before apply = %q, want %q", got, want)
+ }
+ if err := projector.Apply(ctx, planAlpha); err != nil {
+ t.Fatalf("projector.Apply(alpha) error = %v", err)
+ }
+ if got, want := runtime.Version(), int64(1); got != want {
+ t.Fatalf("runtime.Version() after alpha apply = %d, want %d", got, want)
+ }
+ if got, want := dispatchProjectedToolName(t, runtime), "alpha"; got != want {
+ t.Fatalf("DispatchToolPreCall() after alpha apply = %q, want %q", got, want)
+ }
+
+ planBeta, err := projector.Build(ctx, []resources.Record[hookspkg.HookDecl]{
+ testHookBindingRecord(t, 2, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-beta",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ }),
+ })
+ if err != nil {
+ t.Fatalf("projector.Build(beta) error = %v", err)
+ }
+ if got, want := runtime.Version(), int64(1); got != want {
+ t.Fatalf("runtime.Version() after beta build = %d, want %d", got, want)
+ }
+ if got, want := dispatchProjectedToolName(t, runtime), "alpha"; got != want {
+ t.Fatalf("DispatchToolPreCall() after beta build = %q, want %q", got, want)
+ }
+ if err := projector.Apply(ctx, planBeta); err != nil {
+ t.Fatalf("projector.Apply(beta) error = %v", err)
+ }
+ if got, want := runtime.Version(), int64(2); got != want {
+ t.Fatalf("runtime.Version() after beta apply = %d, want %d", got, want)
+ }
+ if got, want := dispatchProjectedToolName(t, runtime), "beta"; got != want {
+ t.Fatalf("DispatchToolPreCall() after beta apply = %q, want %q", got, want)
+ }
+}
+
+func TestHookBindingProjectorBuildFailurePreservesAppliedRuntimeState(t *testing.T) {
+ t.Parallel()
+
+ runtime := hookspkg.NewHooks(
+ hookspkg.WithLogger(discardLogger()),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(map[string]hookspkg.Executor{
+ "tool-stable": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, _ hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ name := "stable"
+ return hookspkg.ToolCallPatch{ToolName: &name}, nil
+ },
+ ),
+ })),
+ )
+ t.Cleanup(runtime.Close)
+
+ projector := newHookBindingProjector(runtime)
+ ctx := testutil.Context(t)
+
+ plan, err := projector.Build(ctx, []resources.Record[hookspkg.HookDecl]{
+ testHookBindingRecord(t, 1, resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}, hookspkg.HookDecl{
+ Name: "tool-stable",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{ToolName: "Read"},
+ }),
+ })
+ if err != nil {
+ t.Fatalf("projector.Build(stable) error = %v", err)
+ }
+ if err := projector.Apply(ctx, plan); err != nil {
+ t.Fatalf("projector.Apply(stable) error = %v", err)
+ }
+
+ _, err = projector.Build(ctx, []resources.Record[hookspkg.HookDecl]{
+ testHookBindingRecord(t, 2, resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}, hookspkg.HookDecl{
+ Name: "tool-missing",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{ToolName: "Read"},
+ }),
+ })
+ if err == nil {
+ t.Fatal("projector.Build(missing executor) error = nil, want non-nil")
+ }
+ if got, want := dispatchProjectedToolName(t, runtime), "stable"; got != want {
+ t.Fatalf("DispatchToolPreCall() after failed build = %q, want %q", got, want)
+ }
+}
+
+func TestHookBindingProjectorPreservesPermissionEscalationGuard(t *testing.T) {
+ t.Parallel()
+
+ runtime := hookspkg.NewHooks(
+ hookspkg.WithLogger(discardLogger()),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(nil)),
+ )
+ t.Cleanup(runtime.Close)
+
+ projector := newHookBindingProjector(runtime)
+ ctx := testutil.Context(t)
+
+ plan, err := projector.Build(ctx, []resources.Record[hookspkg.HookDecl]{
+ testHookBindingRecord(t, 1, resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}, hookspkg.HookDecl{
+ Name: "permission-escalation",
+ Event: hookspkg.HookPermissionRequest,
+ Source: hookspkg.HookSourceSkill,
+ Mode: hookspkg.HookModeSync,
+ Command: "/bin/sh",
+ Args: []string{"-c", "printf '{\"decision\":\"allow-once\",\"decision_class\":\"patched\"}'"},
+ }),
+ })
+ if err != nil {
+ t.Fatalf("projector.Build(permission) error = %v", err)
+ }
+ if err := projector.Apply(ctx, plan); err != nil {
+ t.Fatalf("projector.Apply(permission) error = %v", err)
+ }
+
+ payload, err := runtime.DispatchPermissionRequest(ctx, hookspkg.PermissionRequestPayload{
+ PayloadBase: hookspkg.PayloadBase{Event: hookspkg.HookPermissionRequest},
+ RequestID: "perm-1",
+ Action: "session/request_permission",
+ Resource: "/tmp/secret.txt",
+ Decision: "reject-once",
+ DecisionClass: "interactive",
+ ToolCall: hookspkg.PermissionToolCall{Kind: "Read"},
+ })
+ if err != nil {
+ t.Fatalf("DispatchPermissionRequest() error = %v", err)
+ }
+ if got, want := payload.Decision, "reject-once"; got != want {
+ t.Fatalf("payload.Decision = %q, want %q", got, want)
+ }
+ if got, want := payload.DecisionClass, "interactive"; got != want {
+ t.Fatalf("payload.DecisionClass = %q, want %q", got, want)
+ }
+}
+
+func TestHookBindingCodecPreservesInternalDeclarationFields(t *testing.T) {
+ t.Parallel()
+
+ codec, err := newHookBindingCodec()
+ if err != nil {
+ t.Fatalf("newHookBindingCodec() error = %v", err)
+ }
+
+ spec := hookspkg.HookDecl{
+ Name: "workspace-context",
+ Event: hookspkg.HookPromptPostAssemble,
+ Source: hookspkg.HookSourceSkill,
+ Mode: hookspkg.HookModeSync,
+ Priority: 0,
+ PrioritySet: true,
+ Timeout: 2 * time.Second,
+ ExecutorKind: hookspkg.HookExecutorSubprocess,
+ Command: "node",
+ Args: []string{"dist/index.js", "hook", "prompt_post_assemble"},
+ WorkingDir: "/tmp/extensions/prompt-enhancer",
+ Env: map[string]string{"AGH_TEST_MARKER": "1"},
+ Metadata: map[string]string{"extension": "prompt-enhancer"},
+ SkillSource: hookspkg.HookSkillSourceUser,
+ }
+
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ t.Fatalf("codec.Encode() error = %v", err)
+ }
+
+ decoded, err := codec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ encoded,
+ )
+ if err != nil {
+ t.Fatalf("codec.DecodeAndValidate() error = %v", err)
+ }
+
+ if decoded.Priority != 0 {
+ t.Fatalf("decoded.Priority = %d, want explicit zero", decoded.Priority)
+ }
+ if !decoded.PrioritySet {
+ t.Fatal("decoded.PrioritySet = false, want true")
+ }
+ if got, want := decoded.WorkingDir, spec.WorkingDir; got != want {
+ t.Fatalf("decoded.WorkingDir = %q, want %q", got, want)
+ }
+ if got, want := decoded.SkillSource, spec.SkillSource; got != want {
+ t.Fatalf("decoded.SkillSource = %q, want %q", got, want)
+ }
+ if !testutil.EqualStringSlices(decoded.Args, spec.Args) {
+ t.Fatalf("decoded.Args = %#v, want %#v", decoded.Args, spec.Args)
+ }
+ if got, want := decoded.Metadata["extension"], spec.Metadata["extension"]; got != want {
+ t.Fatalf("decoded.Metadata[extension] = %q, want %q", got, want)
+ }
+ if got, want := decoded.Env["AGH_TEST_MARKER"], spec.Env["AGH_TEST_MARKER"]; got != want {
+ t.Fatalf("decoded.Env[AGH_TEST_MARKER] = %q, want %q", got, want)
+ }
+}
+
+func TestHookBindingReconcileFiresToolHookThroughNotifierUnit(t *testing.T) {
+ t.Parallel()
+
+ toolPayloads := make(chan hookspkg.ToolPreCallPayload, 1)
+ h := newHookBindingUnitHarness(t, map[string]hookspkg.Executor{
+ "tool-hook": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ toolPayloads <- payload
+ return hookspkg.ToolCallPatch{}, nil
+ },
+ ),
+ })
+
+ h.putBinding(t, "tool-hook", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-hook",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.RunBoot() error = %v", err)
+ }
+
+ h.notifier.OnAgentEventForSession(testutil.Context(t), unitIntegrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+
+ select {
+ case payload := <-toolPayloads:
+ if payload.SessionID != "sess-1" || payload.WorkspaceID != "ws-1" || payload.ToolName != "Read" {
+ t.Fatalf("payload = %#v, want sess-1/ws-1/Read", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for reconciled tool hook")
+ }
+}
+
+func TestHookBindingReconcileFailurePreservesAppliedRuntimeStateUnit(t *testing.T) {
+ t.Parallel()
+
+ toolPayloads := make(chan hookspkg.ToolPreCallPayload, 2)
+ h := newHookBindingUnitHarness(t, map[string]hookspkg.Executor{
+ "tool-stable": hookspkg.NewTypedNativeExecutor(
+ func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.ToolPreCallPayload) (hookspkg.ToolCallPatch, error) {
+ toolPayloads <- payload
+ return hookspkg.ToolCallPatch{}, nil
+ },
+ ),
+ })
+
+ record := h.putBinding(t, "tool-hook", 0, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-stable",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.RunBoot(stable) error = %v", err)
+ }
+
+ h.notifier.OnAgentEventForSession(testutil.Context(t), unitIntegrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+ select {
+ case <-toolPayloads:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for initial stable hook dispatch")
+ }
+
+ h.putBinding(t, "tool-hook", record.Version, resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "ws-1",
+ }, hookspkg.HookDecl{
+ Name: "tool-missing",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceNative,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ Matcher: hookspkg.HookMatcher{
+ AgentName: "codex",
+ ToolName: "Read",
+ },
+ })
+ if err := h.driver.RunBoot(testutil.Context(t)); err == nil {
+ t.Fatal("driver.RunBoot(broken) error = nil, want missing executor failure")
+ }
+
+ h.notifier.OnAgentEventForSession(testutil.Context(t), unitIntegrationSession(), acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustMarshalJSON(t, toolEventRaw("tool_call", "", nil)),
+ })
+ select {
+ case payload := <-toolPayloads:
+ if payload.SessionID != "sess-1" || payload.ToolName != "Read" {
+ t.Fatalf("post-failure payload = %#v, want stable tool payload", payload)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for preserved tool hook after failure")
+ }
+}
+
+func testHookBindingRecord(
+ t *testing.T,
+ version int64,
+ scope resources.ResourceScope,
+ decl hookspkg.HookDecl,
+) resources.Record[hookspkg.HookDecl] {
+ t.Helper()
+
+ spec, err := validateHookBindingSpec(testutil.Context(t), scope, decl)
+ if err != nil {
+ t.Fatalf("validateHookBindingSpec() error = %v", err)
+ }
+
+ return resources.Record[hookspkg.HookDecl]{
+ Kind: hookBindingResourceKind,
+ ID: decl.Name,
+ Version: version,
+ Scope: scope.Normalize(),
+ Spec: spec,
+ }
+}
+
+func dispatchProjectedToolName(t *testing.T, runtime *hookspkg.Hooks) string {
+ t.Helper()
+
+ payload, err := runtime.DispatchToolPreCall(testutil.Context(t), hookspkg.ToolPreCallPayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookToolPreCall,
+ Timestamp: time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC),
+ },
+ SessionContext: hookspkg.SessionContext{
+ SessionID: "sess-1",
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ Workspace: "/tmp/ws-1",
+ },
+ TurnContext: hookspkg.TurnContext{TurnID: "turn-1"},
+ ToolCallRef: hookspkg.ToolCallRef{
+ ToolCallID: "tool-1",
+ ToolName: "Read",
+ },
+ })
+ if err != nil {
+ t.Fatalf("DispatchToolPreCall() error = %v", err)
+ }
+ return payload.ToolName
+}
+
+func toolEventRaw(sessionUpdate string, status string, toolResult any) map[string]any {
+ payload := map[string]any{
+ "sessionUpdate": sessionUpdate,
+ "toolCallId": "tool-1",
+ "kind": "read",
+ "title": "Read",
+ "rawInput": map[string]any{
+ "path": "/tmp/demo.txt",
+ },
+ "_meta": map[string]any{
+ "claudeCode": map[string]any{
+ "toolName": "Read",
+ },
+ },
+ }
+ if status != "" {
+ payload["status"] = status
+ }
+ if toolResult != nil {
+ payload["rawOutput"] = toolResult
+ }
+ return payload
+}
+
+func permissionEventRaw(requestID string, decision string, toolKind string) map[string]any {
+ payload := map[string]any{
+ "request_id": requestID,
+ "tool_input": map[string]any{
+ "path": "/tmp/secret.txt",
+ },
+ "tool_call": map[string]any{
+ "id": "tool-1",
+ "kind": toolKind,
+ "title": toolKind,
+ },
+ }
+ if decision != "" {
+ payload["decision"] = decision
+ }
+ return payload
+}
+
+func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
+ t.Helper()
+
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ return encoded
+}
+
+type hookBindingUnitHarness struct {
+ store resources.Store[hookspkg.HookDecl]
+ driver resources.ReconcileDriver
+ notifier *hooksNotifier
+ actor resources.MutationActor
+}
+
+func newHookBindingUnitHarness(
+ t *testing.T,
+ nativeExecutors map[string]hookspkg.Executor,
+) *hookBindingUnitHarness {
+ t.Helper()
+
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ codec, err := newHookBindingCodec()
+ if err != nil {
+ t.Fatalf("newHookBindingCodec() error = %v", err)
+ }
+ store, err := newHookBindingStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("newHookBindingStore() error = %v", err)
+ }
+ runtime := hookspkg.NewHooks(
+ hookspkg.WithLogger(discardLogger()),
+ hookspkg.WithExecutorResolver(daemonExecutorResolver(nativeExecutors)),
+ )
+ t.Cleanup(runtime.Close)
+
+ registration, err := resources.NewTypedProjectorRegistration(codec, newHookBindingProjector(runtime))
+ if err != nil {
+ t.Fatalf("NewTypedProjectorRegistration() error = %v", err)
+ }
+ driver, err := resources.NewReconcileDriver(
+ kernel,
+ resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "unit-control",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "unit"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ []resources.ProjectorRegistration{registration},
+ resources.WithReconcileLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := driver.Close(testutil.Context(t)); err != nil {
+ t.Fatalf("driver.Close() error = %v", err)
+ }
+ })
+
+ notifier := newHooksNotifier(discardLogger(), func() time.Time {
+ return time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ })
+ notifier.setRuntime(runtime, nil)
+
+ return &hookBindingUnitHarness{
+ store: store,
+ driver: driver,
+ notifier: notifier,
+ actor: resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "unit-writer",
+ Source: resources.ResourceSource{Kind: resources.ResourceSourceKind("daemon"), ID: "unit"},
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ }
+}
+
+func (h *hookBindingUnitHarness) putBinding(
+ t *testing.T,
+ id string,
+ expectedVersion int64,
+ scope resources.ResourceScope,
+ decl hookspkg.HookDecl,
+) resources.Record[hookspkg.HookDecl] {
+ t.Helper()
+
+ spec, err := validateHookBindingSpec(testutil.Context(t), scope, decl)
+ if err != nil {
+ t.Fatalf("validateHookBindingSpec() error = %v", err)
+ }
+ record, err := h.store.Put(testutil.Context(t), h.actor, resources.Draft[hookspkg.HookDecl]{
+ ID: id,
+ Scope: scope,
+ ExpectedVersion: expectedVersion,
+ Spec: spec,
+ })
+ if err != nil {
+ t.Fatalf("store.Put(%q) error = %v", id, err)
+ }
+ return record
+}
+
+func unitIntegrationSession() *session.Session {
+ now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ return &session.Session{
+ ID: "sess-1",
+ Name: "demo",
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ Workspace: "/tmp/ws-1",
+ Type: session.SessionTypeUser,
+ State: session.StateActive,
+ CreatedAt: now.Add(-time.Minute),
+ UpdatedAt: now,
+ }
+}
diff --git a/internal/daemon/hook_bindings.go b/internal/daemon/hook_bindings.go
new file mode 100644
index 000000000..7591b1b46
--- /dev/null
+++ b/internal/daemon/hook_bindings.go
@@ -0,0 +1,232 @@
+package daemon
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const hookBindingManagedIDPrefix = "daemon.sync.hook_binding."
+
+type hookBindingPublisher interface {
+ Sync(context.Context) error
+}
+
+type hookBindingDeclarationProvider = hookspkg.DeclarationProvider
+
+type hookBindingPublisherFunc func(context.Context) error
+
+func (f hookBindingPublisherFunc) Sync(ctx context.Context) error {
+ if f == nil {
+ return nil
+ }
+ return f(ctx)
+}
+
+type hookBindingSourceSyncer struct {
+ store resources.Store[hookspkg.HookDecl]
+ codec resources.KindCodec[hookspkg.HookDecl]
+ actor resources.MutationActor
+ logger *slog.Logger
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ providers []hookBindingDeclarationProvider
+}
+
+func newHookBindingSourceSyncer(
+ store resources.Store[hookspkg.HookDecl],
+ codec resources.KindCodec[hookspkg.HookDecl],
+ actor resources.MutationActor,
+ logger *slog.Logger,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+ providers ...hookBindingDeclarationProvider,
+) hookBindingPublisher {
+ if store == nil || codec == nil {
+ return nil
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &hookBindingSourceSyncer{
+ store: store,
+ codec: codec,
+ actor: actor,
+ logger: logger,
+ trigger: trigger,
+ providers: append([]hookBindingDeclarationProvider(nil), providers...),
+ }
+}
+
+func hookBindingSyncActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "hook-binding-sync",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "hook-binding-sync",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *hookBindingSourceSyncer) Sync(ctx context.Context) error {
+ if s == nil {
+ return nil
+ }
+ if ctx == nil {
+ return errors.New("daemon: hook binding sync context is required")
+ }
+
+ desired, err := s.desiredBindings(ctx)
+ if err != nil {
+ return err
+ }
+
+ source := s.actor.Source
+ current, err := s.store.List(ctx, s.actor, resources.ResourceFilter{
+ Source: &source,
+ })
+ if err != nil {
+ return fmt.Errorf("daemon: list managed hook bindings: %w", err)
+ }
+
+ currentByID := make(map[string]resources.Record[hookspkg.HookDecl], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredBinding := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameBinding(existing, desiredBinding.scope, desiredBinding.spec) {
+ delete(currentByID, id)
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.store.Put(ctx, s.actor, resources.Draft[hookspkg.HookDecl]{
+ ID: desiredBinding.id,
+ Scope: desiredBinding.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredBinding.spec,
+ }); err != nil {
+ return fmt.Errorf("daemon: sync hook binding %q: %w", desiredBinding.id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+
+ for _, stale := range currentByID {
+ if err := s.store.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return fmt.Errorf("daemon: delete stale hook binding %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+
+ if changed && s.trigger != nil {
+ if err := s.trigger(ctx, hookBindingResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type desiredHookBinding struct {
+ id string
+ scope resources.ResourceScope
+ spec hookspkg.HookDecl
+}
+
+func (s *hookBindingSourceSyncer) desiredBindings(ctx context.Context) (map[string]*desiredHookBinding, error) {
+ bindings := make(map[string]*desiredHookBinding)
+ for _, provider := range s.providers {
+ if provider == nil {
+ continue
+ }
+ decls, err := provider(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, decl := range decls {
+ scope := hookBindingScope(decl)
+ spec, err := validateHookBindingSpec(ctx, scope, decl)
+ if err != nil {
+ return nil, err
+ }
+ id, err := s.bindingID(scope, spec)
+ if err != nil {
+ return nil, err
+ }
+ bindings[id] = &desiredHookBinding{
+ id: id,
+ scope: scope,
+ spec: spec,
+ }
+ }
+ }
+ return bindings, nil
+}
+
+func (s *hookBindingSourceSyncer) bindingID(
+ scope resources.ResourceScope,
+ spec hookspkg.HookDecl,
+) (string, error) {
+ encoded, err := s.codec.Encode(spec)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256([]byte(string(scope.Kind) + "\x00" + scope.ID + "\x00" + string(encoded)))
+ return hookBindingManagedIDPrefix + strings.ToLower(spec.Source.String()) + "." + hex.EncodeToString(sum[:12]), nil
+}
+
+func hookBindingScope(decl hookspkg.HookDecl) resources.ResourceScope {
+ workspaceID := strings.TrimSpace(decl.Matcher.WorkspaceID)
+ if workspaceID != "" {
+ return resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: workspaceID,
+ }
+ }
+ return resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+}
+
+func hookCloneDeclarations(decls []hookspkg.HookDecl) []hookspkg.HookDecl {
+ if len(decls) == 0 {
+ return nil
+ }
+ cloned := make([]hookspkg.HookDecl, 0, len(decls))
+ for _, decl := range decls {
+ cloned = append(cloned, cloneDaemonHookDecl(decl))
+ }
+ return cloned
+}
+
+func (s *hookBindingSourceSyncer) sameBinding(
+ record resources.Record[hookspkg.HookDecl],
+ scope resources.ResourceScope,
+ spec hookspkg.HookDecl,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+
+ currentEncoded, err := s.codec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ desiredEncoded, err := s.codec.Encode(spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, desiredEncoded)
+}
diff --git a/internal/daemon/hooks_bridge.go b/internal/daemon/hooks_bridge.go
index 3216ef292..1a67078cf 100644
--- a/internal/daemon/hooks_bridge.go
+++ b/internal/daemon/hooks_bridge.go
@@ -19,7 +19,6 @@ import (
)
type hookRuntime interface {
- Rebuild(context.Context) error
Close()
Version() int64
DispatchSessionPreCreate(
@@ -40,6 +39,23 @@ type hookRuntime interface {
) (hookspkg.SessionPostResumePayload, error)
DispatchSessionPreStop(context.Context, hookspkg.SessionPreStopPayload) (hookspkg.SessionPreStopPayload, error)
DispatchSessionPostStop(context.Context, hookspkg.SessionPostStopPayload) (hookspkg.SessionPostStopPayload, error)
+ DispatchEnvironmentPrepare(
+ context.Context,
+ hookspkg.EnvironmentPreparePayload,
+ ) (hookspkg.EnvironmentPreparePayload, error)
+ DispatchEnvironmentReady(
+ context.Context,
+ hookspkg.EnvironmentReadyPayload,
+ ) (hookspkg.EnvironmentReadyPayload, error)
+ DispatchEnvironmentSyncBefore(
+ context.Context,
+ hookspkg.EnvironmentSyncBeforePayload,
+ ) (hookspkg.EnvironmentSyncBeforePayload, error)
+ DispatchEnvironmentSyncAfter(
+ context.Context,
+ hookspkg.EnvironmentSyncAfterPayload,
+ ) (hookspkg.EnvironmentSyncAfterPayload, error)
+ DispatchEnvironmentStop(context.Context, hookspkg.EnvironmentStopPayload) (hookspkg.EnvironmentStopPayload, error)
DispatchInputPreSubmit(context.Context, hookspkg.InputPreSubmitPayload) (hookspkg.InputPreSubmitPayload, error)
DispatchPromptPostAssemble(context.Context, hookspkg.PromptPayload) (hookspkg.PromptPayload, error)
DispatchEventPreRecord(context.Context, hookspkg.EventPreRecordPayload) (hookspkg.EventPreRecordPayload, error)
@@ -77,6 +93,21 @@ type hookRuntime interface {
DispatchMessageStart(context.Context, hookspkg.MessageStartPayload) (hookspkg.MessageStartPayload, error)
DispatchMessageDelta(context.Context, hookspkg.MessageDeltaPayload) (hookspkg.MessageDeltaPayload, error)
DispatchMessageEnd(context.Context, hookspkg.MessageEndPayload) (hookspkg.MessageEndPayload, error)
+ DispatchToolPreCall(context.Context, hookspkg.ToolPreCallPayload) (hookspkg.ToolPreCallPayload, error)
+ DispatchToolPostCall(context.Context, hookspkg.ToolPostCallPayload) (hookspkg.ToolPostCallPayload, error)
+ DispatchToolPostError(context.Context, hookspkg.ToolPostErrorPayload) (hookspkg.ToolPostErrorPayload, error)
+ DispatchPermissionRequest(
+ context.Context,
+ hookspkg.PermissionRequestPayload,
+ ) (hookspkg.PermissionRequestPayload, error)
+ DispatchPermissionResolved(
+ context.Context,
+ hookspkg.PermissionResolvedPayload,
+ ) (hookspkg.PermissionResolvedPayload, error)
+ DispatchPermissionDenied(
+ context.Context,
+ hookspkg.PermissionDeniedPayload,
+ ) (hookspkg.PermissionDeniedPayload, error)
DispatchContextPreCompact(
context.Context,
hookspkg.ContextPreCompactPayload,
@@ -85,7 +116,6 @@ type hookRuntime interface {
context.Context,
hookspkg.ContextPostCompactPayload,
) (hookspkg.ContextPostCompactPayload, error)
- OnAgentEvent(context.Context, string, any)
}
type sessionLifecycleObserver interface {
@@ -196,11 +226,14 @@ type hooksNotifier struct {
var _ session.Notifier = (*hooksNotifier)(nil)
var _ session.LifecycleHooks = (*hooksNotifier)(nil)
+var _ session.EnvironmentHooks = (*hooksNotifier)(nil)
var _ session.PromptHooks = (*hooksNotifier)(nil)
var _ session.EventHooks = (*hooksNotifier)(nil)
var _ session.AgentHooks = (*hooksNotifier)(nil)
var _ session.ConversationHooks = (*hooksNotifier)(nil)
var _ session.CompactionHooks = (*hooksNotifier)(nil)
+var _ session.AgentEventNotifier = (*hooksNotifier)(nil)
+var _ session.EnvironmentLifecycleNotifier = (*hooksNotifier)(nil)
func newHooksNotifier(logger *slog.Logger, now func() time.Time) *hooksNotifier {
if logger == nil {
@@ -241,7 +274,6 @@ func (n *hooksNotifier) DispatchSessionPreCreate(
n,
hookspkg.HookSessionPreCreate,
payload,
- true,
hookRuntime.DispatchSessionPreCreate,
)
}
@@ -255,7 +287,6 @@ func (n *hooksNotifier) DispatchSessionPostCreate(
n,
hookspkg.HookSessionPostCreate,
payload,
- true,
hookRuntime.DispatchSessionPostCreate,
)
}
@@ -269,7 +300,6 @@ func (n *hooksNotifier) DispatchSessionPreResume(
n,
hookspkg.HookSessionPreResume,
payload,
- true,
hookRuntime.DispatchSessionPreResume,
)
}
@@ -283,7 +313,6 @@ func (n *hooksNotifier) DispatchSessionPostResume(
n,
hookspkg.HookSessionPostResume,
payload,
- true,
hookRuntime.DispatchSessionPostResume,
)
}
@@ -297,7 +326,6 @@ func (n *hooksNotifier) DispatchSessionPreStop(
n,
hookspkg.HookSessionPreStop,
payload,
- true,
hookRuntime.DispatchSessionPreStop,
)
}
@@ -311,11 +339,75 @@ func (n *hooksNotifier) DispatchSessionPostStop(
n,
hookspkg.HookSessionPostStop,
payload,
- true,
hookRuntime.DispatchSessionPostStop,
)
}
+func (n *hooksNotifier) DispatchEnvironmentPrepare(
+ ctx context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+) (hookspkg.EnvironmentPreparePayload, error) {
+ return dispatchRuntime(
+ ctx,
+ n,
+ hookspkg.HookEnvironmentPrepare,
+ payload,
+ hookRuntime.DispatchEnvironmentPrepare,
+ )
+}
+
+func (n *hooksNotifier) DispatchEnvironmentReady(
+ ctx context.Context,
+ payload hookspkg.EnvironmentReadyPayload,
+) (hookspkg.EnvironmentReadyPayload, error) {
+ return dispatchRuntime(
+ ctx,
+ n,
+ hookspkg.HookEnvironmentReady,
+ payload,
+ hookRuntime.DispatchEnvironmentReady,
+ )
+}
+
+func (n *hooksNotifier) DispatchEnvironmentSyncBefore(
+ ctx context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ return dispatchRuntime(
+ ctx,
+ n,
+ hookspkg.HookEnvironmentSyncBefore,
+ payload,
+ hookRuntime.DispatchEnvironmentSyncBefore,
+ )
+}
+
+func (n *hooksNotifier) DispatchEnvironmentSyncAfter(
+ ctx context.Context,
+ payload hookspkg.EnvironmentSyncAfterPayload,
+) (hookspkg.EnvironmentSyncAfterPayload, error) {
+ return dispatchRuntime(
+ ctx,
+ n,
+ hookspkg.HookEnvironmentSyncAfter,
+ payload,
+ hookRuntime.DispatchEnvironmentSyncAfter,
+ )
+}
+
+func (n *hooksNotifier) DispatchEnvironmentStop(
+ ctx context.Context,
+ payload hookspkg.EnvironmentStopPayload,
+) (hookspkg.EnvironmentStopPayload, error) {
+ return dispatchRuntime(
+ ctx,
+ n,
+ hookspkg.HookEnvironmentStop,
+ payload,
+ hookRuntime.DispatchEnvironmentStop,
+ )
+}
+
func (n *hooksNotifier) DispatchInputPreSubmit(
ctx context.Context,
payload hookspkg.InputPreSubmitPayload,
@@ -325,7 +417,6 @@ func (n *hooksNotifier) DispatchInputPreSubmit(
n,
hookspkg.HookInputPreSubmit,
payload,
- false,
hookRuntime.DispatchInputPreSubmit,
)
}
@@ -339,7 +430,6 @@ func (n *hooksNotifier) DispatchPromptPostAssemble(
n,
hookspkg.HookPromptPostAssemble,
payload,
- false,
hookRuntime.DispatchPromptPostAssemble,
)
}
@@ -353,7 +443,6 @@ func (n *hooksNotifier) DispatchEventPreRecord(
n,
hookspkg.HookEventPreRecord,
payload,
- false,
hookRuntime.DispatchEventPreRecord,
)
}
@@ -367,7 +456,6 @@ func (n *hooksNotifier) DispatchEventPostRecord(
n,
hookspkg.HookEventPostRecord,
payload,
- false,
hookRuntime.DispatchEventPostRecord,
)
}
@@ -381,7 +469,6 @@ func (n *hooksNotifier) DispatchAgentPreStart(
n,
hookspkg.HookAgentPreStart,
payload,
- true,
hookRuntime.DispatchAgentPreStart,
)
}
@@ -395,7 +482,6 @@ func (n *hooksNotifier) DispatchAgentSpawned(
n,
hookspkg.HookAgentSpawned,
payload,
- true,
hookRuntime.DispatchAgentSpawned,
)
}
@@ -409,7 +495,6 @@ func (n *hooksNotifier) DispatchAgentCrashed(
n,
hookspkg.HookAgentCrashed,
payload,
- true,
hookRuntime.DispatchAgentCrashed,
)
}
@@ -423,7 +508,6 @@ func (n *hooksNotifier) DispatchAgentStopped(
n,
hookspkg.HookAgentStopped,
payload,
- true,
hookRuntime.DispatchAgentStopped,
)
}
@@ -437,7 +521,6 @@ func (n *hooksNotifier) DispatchTurnStart(
n,
hookspkg.HookTurnStart,
payload,
- false,
hookRuntime.DispatchTurnStart,
)
}
@@ -451,7 +534,6 @@ func (n *hooksNotifier) DispatchTurnEnd(
n,
hookspkg.HookTurnEnd,
payload,
- false,
hookRuntime.DispatchTurnEnd,
)
}
@@ -465,7 +547,6 @@ func (n *hooksNotifier) DispatchMessageStart(
n,
hookspkg.HookMessageStart,
payload,
- false,
hookRuntime.DispatchMessageStart,
)
}
@@ -479,7 +560,6 @@ func (n *hooksNotifier) DispatchMessageDelta(
n,
hookspkg.HookMessageDelta,
payload,
- false,
hookRuntime.DispatchMessageDelta,
)
}
@@ -493,7 +573,6 @@ func (n *hooksNotifier) DispatchMessageEnd(
n,
hookspkg.HookMessageEnd,
payload,
- false,
hookRuntime.DispatchMessageEnd,
)
}
@@ -507,7 +586,6 @@ func (n *hooksNotifier) DispatchContextPreCompact(
n,
hookspkg.HookContextPreCompact,
payload,
- false,
hookRuntime.DispatchContextPreCompact,
)
}
@@ -521,18 +599,32 @@ func (n *hooksNotifier) DispatchContextPostCompact(
n,
hookspkg.HookContextPostCompact,
payload,
- false,
hookRuntime.DispatchContextPostCompact,
)
}
func (n *hooksNotifier) OnAgentEvent(ctx context.Context, sessionID string, event any) {
+ n.dispatchAgentEvent(ctx, hookspkg.SessionContext{SessionID: strings.TrimSpace(sessionID)}, event)
+}
+
+func (n *hooksNotifier) OnAgentEventForSession(ctx context.Context, sess *session.Session, event any) {
+ n.dispatchAgentEvent(ctx, hookSessionContext(sess), event)
+}
+
+func (n *hooksNotifier) OnEnvironmentLifecycleEvent(ctx context.Context, event session.EnvironmentLifecycleEvent) {
+ _, agentEventNotify := n.runtime()
+ if notifier, ok := agentEventNotify.(session.EnvironmentLifecycleNotifier); ok {
+ notifier.OnEnvironmentLifecycleEvent(ctx, event)
+ }
+}
+
+func (n *hooksNotifier) dispatchAgentEvent(ctx context.Context, sessionCtx hookspkg.SessionContext, event any) {
hooks, agentEventNotify := n.runtime()
if agentEventNotify != nil {
- agentEventNotify.OnAgentEvent(ctx, sessionID, event)
+ agentEventNotify.OnAgentEvent(ctx, sessionCtx.SessionID, event)
}
if hooks != nil {
- hooks.OnAgentEvent(ctx, sessionID, event)
+ dispatchACPAgentHookEvent(ctx, n.logger, hooks, sessionCtx, event, n.timestamp())
}
}
@@ -557,7 +649,6 @@ func dispatchRuntime[P any](
n *hooksNotifier,
event hookspkg.HookEvent,
payload P,
- rebuild bool,
dispatch runtimeDispatchFunc[P],
) (P, error) {
hooks, _ := n.runtime()
@@ -567,16 +658,6 @@ func dispatchRuntime[P any](
if ctx == nil {
return payload, fmt.Errorf("daemon: dispatch %s requires a non-nil context", event)
}
- if rebuild {
- if err := hooks.Rebuild(ctx); err != nil {
- n.logger.WarnContext(
- ctx,
- "daemon: rebuild hooks before dispatch failed",
- "event", event.String(),
- "error", err,
- )
- }
- }
return dispatch(hooks, ctx, payload)
}
diff --git a/internal/daemon/notifier_test.go b/internal/daemon/notifier_test.go
index ab6a9a018..6c7fbdbea 100644
--- a/internal/daemon/notifier_test.go
+++ b/internal/daemon/notifier_test.go
@@ -2,12 +2,13 @@ package daemon
import (
"context"
- "errors"
+ "encoding/json"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/pedronauck/agh/internal/acp"
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/skills"
@@ -21,10 +22,6 @@ func TestHooksNotifierDispatchesLifecycleAgentAndStreamEvents(t *testing.T) {
fixedNow := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
var order []string
runtime := &fakeHookRuntime{
- onRebuild: func(context.Context) error {
- order = append(order, "rebuild")
- return nil
- },
onDispatchCreate: func(_ context.Context, payload hookspkg.SessionPostCreatePayload) error {
order = append(order, "create")
if payload.Event != hookspkg.HookSessionPostCreate {
@@ -97,8 +94,15 @@ func TestHooksNotifierDispatchesLifecycleAgentAndStreamEvents(t *testing.T) {
}
return nil
},
- onAgentEvent: func(context.Context, string, any) {
- order = append(order, "hook-agent")
+ onToolPreCall: func(_ context.Context, payload hookspkg.ToolPreCallPayload) error {
+ order = append(order, "tool-pre")
+ if payload.SessionID != "sess-created" || payload.WorkspaceID != "ws-1" {
+ t.Fatalf("payload session context = %#v, want session metadata", payload.SessionContext)
+ }
+ if payload.ToolName != "Read" {
+ t.Fatalf("payload.ToolName = %q, want %q", payload.ToolName, "Read")
+ }
+ return nil
},
}
agentEvents := &recordingNotifier{}
@@ -181,12 +185,32 @@ func TestHooksNotifierDispatchesLifecycleAgentAndStreamEvents(t *testing.T) {
}); err != nil {
t.Fatalf("DispatchContextPostCompact() error = %v", err)
}
- notifier.OnAgentEvent(testutil.Context(t), "sess-created", struct{ Type string }{Type: "done"})
+ rawToolEvent, err := json.Marshal(map[string]any{
+ "sessionUpdate": "tool_call",
+ "toolCallId": "tool-1",
+ "kind": "read",
+ "rawInput": map[string]any{
+ "path": "/tmp/demo.txt",
+ },
+ "_meta": map[string]any{
+ "claudeCode": map[string]any{
+ "toolName": "Read",
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("json.Marshal(tool event) error = %v", err)
+ }
+ notifier.OnAgentEventForSession(testutil.Context(t), sess, acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: rawToolEvent,
+ })
wantOrder := []string{
- "rebuild",
"create",
- "rebuild",
"stop",
"turn-start",
"message-start",
@@ -195,7 +219,7 @@ func TestHooksNotifierDispatchesLifecycleAgentAndStreamEvents(t *testing.T) {
"turn-end",
"context-pre",
"context-post",
- "hook-agent",
+ "tool-pre",
}
if !testutil.EqualStringSlices(order, wantOrder) {
t.Fatalf("dispatch order = %#v, want %#v", order, wantOrder)
@@ -205,6 +229,55 @@ func TestHooksNotifierDispatchesLifecycleAgentAndStreamEvents(t *testing.T) {
}
}
+func TestHooksNotifierOnAgentEventUsesProvidedSessionIDAndLifecycleNoops(t *testing.T) {
+ t.Parallel()
+
+ var gotSessionID string
+ runtime := &fakeHookRuntime{
+ onToolPreCall: func(_ context.Context, payload hookspkg.ToolPreCallPayload) error {
+ gotSessionID = payload.SessionID
+ return nil
+ },
+ }
+ notifier := newHooksNotifier(discardLogger(), func() time.Time {
+ return time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ })
+ notifier.setRuntime(runtime, &recordingNotifier{})
+
+ notifier.OnSessionCreated(testutil.Context(t), nil)
+ notifier.OnSessionStopped(testutil.Context(t), nil)
+ notifier.OnAgentEvent(testutil.Context(t), "sess-direct", acp.AgentEvent{
+ Type: acp.EventTypeToolCall,
+ SessionID: "acp-session-1",
+ TurnID: "turn-1",
+ ToolCallID: "tool-1",
+ Raw: mustJSON(t, map[string]any{
+ "sessionUpdate": "tool_call",
+ "toolCallId": "tool-1",
+ "kind": "read",
+ "_meta": map[string]any{
+ "claudeCode": map[string]any{
+ "toolName": "Read",
+ },
+ },
+ }),
+ })
+
+ if gotSessionID != "sess-direct" {
+ t.Fatalf("tool hook SessionID = %q, want %q", gotSessionID, "sess-direct")
+ }
+}
+
+func mustJSON(t *testing.T, value any) json.RawMessage {
+ t.Helper()
+
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ return raw
+}
+
func TestDaemonNativeHooksDriveObserverAndDreamCallbacks(t *testing.T) {
t.Parallel()
@@ -364,7 +437,6 @@ func TestDispatchRuntimeAndExecutorResolvers(t *testing.T) {
notifier,
hookspkg.HookSessionPostCreate,
"seed",
- false,
func(_ hookRuntime, _ context.Context, item string) (string, error) {
return item + "-unused", nil
},
@@ -376,13 +448,7 @@ func TestDispatchRuntimeAndExecutorResolvers(t *testing.T) {
t.Fatalf("dispatchRuntime(nil runtime) payload = %q, want %q", payload, "seed")
}
- var rebuildCalls int
- runtime := &fakeHookRuntime{
- onRebuild: func(context.Context) error {
- rebuildCalls++
- return errors.New("rebuild failed")
- },
- }
+ runtime := &fakeHookRuntime{}
notifier.setRuntime(runtime, nil)
result, err := dispatchRuntime(
@@ -390,7 +456,6 @@ func TestDispatchRuntimeAndExecutorResolvers(t *testing.T) {
notifier,
hookspkg.HookEventPreRecord,
"seed",
- false,
func(_ hookRuntime, _ context.Context, item string) (string, error) {
return item + "-ok", nil
},
@@ -401,28 +466,21 @@ func TestDispatchRuntimeAndExecutorResolvers(t *testing.T) {
if result != "seed-ok" {
t.Fatalf("dispatchRuntime(rebuild false) result = %q, want %q", result, "seed-ok")
}
- if rebuildCalls != 0 {
- t.Fatalf("rebuildCalls = %d, want 0 when rebuild=false", rebuildCalls)
- }
result, err = dispatchRuntime(
context.Background(),
notifier,
hookspkg.HookSessionPostCreate,
"seed",
- true,
func(_ hookRuntime, _ context.Context, item string) (string, error) {
- return item + "-after-rebuild", nil
+ return item + "-dispatched", nil
},
)
if err != nil {
- t.Fatalf("dispatchRuntime(rebuild true) error = %v, want nil", err)
- }
- if result != "seed-after-rebuild" {
- t.Fatalf("dispatchRuntime(rebuild true) result = %q, want %q", result, "seed-after-rebuild")
+ t.Fatalf("dispatchRuntime() error = %v, want nil", err)
}
- if rebuildCalls != 1 {
- t.Fatalf("rebuildCalls = %d, want 1", rebuildCalls)
+ if result != "seed-dispatched" {
+ t.Fatalf("dispatchRuntime() result = %q, want %q", result, "seed-dispatched")
}
_, err = dispatchRuntime(
@@ -430,7 +488,6 @@ func TestDispatchRuntimeAndExecutorResolvers(t *testing.T) {
notifier,
hookspkg.HookSessionPostCreate,
"seed",
- false,
func(_ hookRuntime, _ context.Context, item string) (string, error) {
return item, nil
},
diff --git a/internal/daemon/task_runtime.go b/internal/daemon/task_runtime.go
index 85d151709..b924cbe81 100644
--- a/internal/daemon/task_runtime.go
+++ b/internal/daemon/task_runtime.go
@@ -17,6 +17,9 @@ const (
defaultTaskCancelGrace = 5 * time.Second
taskRecoveryReasonBoot = "orphaned_on_boot"
taskRecoverySessionMissing = "missing"
+ taskStopDetailShutdown = "task shutdown"
+ taskStopDetailOrphaned = "task run orphaned"
+ taskStopDetailCancellation = "task cancellation"
)
type taskStore interface {
@@ -414,10 +417,10 @@ func taskStopCause(reason taskpkg.StopReason) session.StopCause {
func taskStopDetail(reason taskpkg.StopReason) string {
switch reason.Normalize() {
case taskpkg.StopReasonShutdown:
- return "task shutdown"
+ return taskStopDetailShutdown
case taskpkg.StopReasonOrphanedRun:
- return "task run orphaned"
+ return taskStopDetailOrphaned
default:
- return "task cancellation"
+ return taskStopDetailCancellation
}
}
diff --git a/internal/daemon/tool_mcp_resources.go b/internal/daemon/tool_mcp_resources.go
new file mode 100644
index 000000000..8ff884717
--- /dev/null
+++ b/internal/daemon/tool_mcp_resources.go
@@ -0,0 +1,731 @@
+package daemon
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ "github.com/pedronauck/agh/internal/resources"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+const (
+ toolManagedIDPrefix = "daemon.sync.tool."
+ mcpServerManagedIDPrefix = "daemon.sync.mcp_server."
+)
+
+type toolMCPPublisher interface {
+ Sync(context.Context) error
+}
+
+type toolMCPPublisherFunc func(context.Context) error
+
+func (f toolMCPPublisherFunc) Sync(ctx context.Context) error {
+ if f == nil {
+ return nil
+ }
+ return f(ctx)
+}
+
+type resourceCatalog[T any] struct {
+ mu sync.RWMutex
+ revision int64
+ records []resources.Record[T]
+ cloneSpec func(T) T
+}
+
+func newResourceCatalog[T any](cloneSpec func(T) T) *resourceCatalog[T] {
+ return &resourceCatalog[T]{cloneSpec: cloneSpec}
+}
+
+func (c *resourceCatalog[T]) Replace(revision int64, records []resources.Record[T]) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.revision = revision
+ c.records = cloneResourceRecords(records, c.cloneSpec)
+}
+
+func (c *resourceCatalog[T]) Snapshot() []resources.Record[T] {
+ if c == nil {
+ return nil
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return cloneResourceRecords(c.records, c.cloneSpec)
+}
+
+func (c *resourceCatalog[T]) Revision() int64 {
+ if c == nil {
+ return 0
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.revision
+}
+
+type resourceCatalogProjectionPlan[T any] struct {
+ kind resources.ResourceKind
+ revision int64
+ operations int
+ records []resources.Record[T]
+}
+
+func (p *resourceCatalogProjectionPlan[T]) Kind() resources.ResourceKind {
+ if p == nil {
+ return ""
+ }
+ return p.kind
+}
+
+func (p *resourceCatalogProjectionPlan[T]) Revision() int64 {
+ if p == nil {
+ return 0
+ }
+ return p.revision
+}
+
+func (p *resourceCatalogProjectionPlan[T]) OperationCount() int {
+ if p == nil {
+ return 0
+ }
+ return p.operations
+}
+
+type resourceCatalogProjector[T any] struct {
+ kind resources.ResourceKind
+ catalog *resourceCatalog[T]
+ cloneSpec func(T) T
+}
+
+func newToolProjector(catalog *resourceCatalog[toolspkg.Tool]) resources.TypedProjector[toolspkg.Tool] {
+ if catalog == nil {
+ return nil
+ }
+ return &resourceCatalogProjector[toolspkg.Tool]{
+ kind: toolspkg.ToolResourceKind,
+ catalog: catalog,
+ cloneSpec: cloneToolSpec,
+ }
+}
+
+func newMCPServerProjector(
+ catalog *resourceCatalog[aghconfig.MCPServer],
+) resources.TypedProjector[aghconfig.MCPServer] {
+ if catalog == nil {
+ return nil
+ }
+ return &resourceCatalogProjector[aghconfig.MCPServer]{
+ kind: aghconfig.MCPServerResourceKind,
+ catalog: catalog,
+ cloneSpec: cloneDaemonMCPServer,
+ }
+}
+
+func (p *resourceCatalogProjector[T]) Kind() resources.ResourceKind {
+ if p == nil {
+ return ""
+ }
+ return p.kind
+}
+
+func (p *resourceCatalogProjector[T]) DependsOn() []resources.ResourceKind {
+ return nil
+}
+
+func (p *resourceCatalogProjector[T]) Build(
+ _ context.Context,
+ records []resources.Record[T],
+) (resources.ProjectionPlan, error) {
+ if p == nil || p.catalog == nil {
+ return nil, errors.New("daemon: resource catalog projector is required")
+ }
+
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ }
+
+ return &resourceCatalogProjectionPlan[T]{
+ kind: p.kind,
+ revision: revision,
+ operations: len(records),
+ records: cloneResourceRecords(records, p.cloneSpec),
+ }, nil
+}
+
+func (p *resourceCatalogProjector[T]) Apply(ctx context.Context, plan resources.ProjectionPlan) error {
+ if p == nil || p.catalog == nil {
+ return errors.New("daemon: resource catalog projector is required")
+ }
+ if ctx == nil {
+ return errors.New("daemon: resource catalog projector apply context is required")
+ }
+
+ typed, ok := plan.(*resourceCatalogProjectionPlan[T])
+ if !ok {
+ return fmt.Errorf("daemon: resource catalog projector plan has type %T", plan)
+ }
+ p.catalog.Replace(typed.revision, typed.records)
+ return nil
+}
+
+type toolPublicationInput struct {
+ sourceKey string
+ scope resources.ResourceScope
+ spec toolspkg.Tool
+}
+
+type mcpServerPublicationInput struct {
+ sourceKey string
+ scope resources.ResourceScope
+ spec aghconfig.MCPServer
+}
+
+type toolMCPDesiredResources struct {
+ tools []toolPublicationInput
+ mcpServers []mcpServerPublicationInput
+}
+
+type toolMCPDeclarationProvider func(context.Context) (toolMCPDesiredResources, error)
+
+type toolMCPSourceSyncer struct {
+ toolStore resources.Store[toolspkg.Tool]
+ toolCodec resources.KindCodec[toolspkg.Tool]
+ mcpStore resources.Store[aghconfig.MCPServer]
+ mcpCodec resources.KindCodec[aghconfig.MCPServer]
+ actor resources.MutationActor
+ logger *slog.Logger
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
+ providers []toolMCPDeclarationProvider
+}
+
+func newToolMCPSourceSyncer(
+ toolStore resources.Store[toolspkg.Tool],
+ toolCodec resources.KindCodec[toolspkg.Tool],
+ mcpStore resources.Store[aghconfig.MCPServer],
+ mcpCodec resources.KindCodec[aghconfig.MCPServer],
+ actor resources.MutationActor,
+ logger *slog.Logger,
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+ providers ...toolMCPDeclarationProvider,
+) toolMCPPublisher {
+ if toolStore == nil || toolCodec == nil || mcpStore == nil || mcpCodec == nil {
+ return nil
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &toolMCPSourceSyncer{
+ toolStore: toolStore,
+ toolCodec: toolCodec,
+ mcpStore: mcpStore,
+ mcpCodec: mcpCodec,
+ actor: actor,
+ logger: logger,
+ trigger: trigger,
+ providers: append([]toolMCPDeclarationProvider(nil), providers...),
+ }
+}
+
+func toolMCPSyncActor() resources.MutationActor {
+ return resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "tool-mcp-sync",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "tool-mcp-sync",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }
+}
+
+func (s *toolMCPSourceSyncer) Sync(ctx context.Context) error {
+ if s == nil {
+ return nil
+ }
+ if ctx == nil {
+ return errors.New("daemon: tool/mcp sync context is required")
+ }
+
+ desired, err := s.desiredResources(ctx)
+ if err != nil {
+ return err
+ }
+
+ toolChanged, err := s.syncTools(ctx, desired.tools)
+ if err != nil {
+ return err
+ }
+ mcpChanged, err := s.syncMCPServers(ctx, desired.mcpServers)
+ if err != nil {
+ return err
+ }
+
+ if toolChanged && s.trigger != nil {
+ if err := s.trigger(ctx, toolspkg.ToolResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+ if mcpChanged && s.trigger != nil {
+ if err := s.trigger(ctx, aghconfig.MCPServerResourceKind, resources.ReconcileReasonWrite); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type desiredToolResource struct {
+ id string
+ scope resources.ResourceScope
+ spec toolspkg.Tool
+ encoded []byte
+}
+
+type desiredMCPServerResource struct {
+ id string
+ scope resources.ResourceScope
+ spec aghconfig.MCPServer
+ encoded []byte
+}
+
+func (s *toolMCPSourceSyncer) desiredResources(ctx context.Context) (struct {
+ tools map[string]desiredToolResource
+ mcpServers map[string]desiredMCPServerResource
+}, error) {
+ desired := struct {
+ tools map[string]desiredToolResource
+ mcpServers map[string]desiredMCPServerResource
+ }{
+ tools: make(map[string]desiredToolResource),
+ mcpServers: make(map[string]desiredMCPServerResource),
+ }
+
+ for _, provider := range s.providers {
+ if provider == nil {
+ continue
+ }
+ items, err := provider(ctx)
+ if err != nil {
+ return desired, err
+ }
+
+ for _, item := range items.tools {
+ spec, encoded, err := validateAndEncodeTool(ctx, s.toolCodec, item.scope, item.spec)
+ if err != nil {
+ return desired, err
+ }
+ id := managedResourceID(toolManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ desired.tools[id] = desiredToolResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ for _, item := range items.mcpServers {
+ spec, encoded, err := validateAndEncodeMCPServer(ctx, s.mcpCodec, item.scope, item.spec)
+ if err != nil {
+ return desired, err
+ }
+ id := managedResourceID(mcpServerManagedIDPrefix, item.scope.Normalize(), item.sourceKey, encoded)
+ desired.mcpServers[id] = desiredMCPServerResource{
+ id: id,
+ scope: item.scope.Normalize(),
+ spec: spec,
+ encoded: encoded,
+ }
+ }
+ }
+
+ return desired, nil
+}
+
+func (s *toolMCPSourceSyncer) syncTools(ctx context.Context, desired map[string]desiredToolResource) (bool, error) {
+ source := s.actor.Source
+ current, err := s.toolStore.List(ctx, s.actor, resources.ResourceFilter{Source: &source})
+ if err != nil {
+ return false, fmt.Errorf("daemon: list managed tools: %w", err)
+ }
+
+ currentByID := make(map[string]resources.Record[toolspkg.Tool], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredTool := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameTool(existing, desiredTool.scope, desiredTool.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.toolStore.Put(ctx, s.actor, resources.Draft[toolspkg.Tool]{
+ ID: desiredTool.id,
+ Scope: desiredTool.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredTool.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync tool %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+
+ for _, stale := range currentByID {
+ if err := s.toolStore.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale tool %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+
+ return changed, nil
+}
+
+func (s *toolMCPSourceSyncer) syncMCPServers(
+ ctx context.Context,
+ desired map[string]desiredMCPServerResource,
+) (bool, error) {
+ source := s.actor.Source
+ current, err := s.mcpStore.List(ctx, s.actor, resources.ResourceFilter{Source: &source})
+ if err != nil {
+ return false, fmt.Errorf("daemon: list managed mcp servers: %w", err)
+ }
+
+ currentByID := make(map[string]resources.Record[aghconfig.MCPServer], len(current))
+ for _, record := range current {
+ currentByID[record.ID] = record
+ }
+
+ changed := false
+ for id, desiredServer := range desired {
+ existing, ok := currentByID[id]
+ if ok && s.sameMCPServer(existing, desiredServer.scope, desiredServer.encoded) {
+ delete(currentByID, id)
+ continue
+ }
+
+ expectedVersion := int64(0)
+ if ok {
+ expectedVersion = existing.Version
+ }
+ if _, err := s.mcpStore.Put(ctx, s.actor, resources.Draft[aghconfig.MCPServer]{
+ ID: desiredServer.id,
+ Scope: desiredServer.scope,
+ ExpectedVersion: expectedVersion,
+ Spec: desiredServer.spec,
+ }); err != nil {
+ return false, fmt.Errorf("daemon: sync mcp server %q: %w", id, err)
+ }
+ changed = true
+ delete(currentByID, id)
+ }
+
+ for _, stale := range currentByID {
+ if err := s.mcpStore.Delete(ctx, s.actor, stale.ID, stale.Version); err != nil {
+ return false, fmt.Errorf("daemon: delete stale mcp server %q: %w", stale.ID, err)
+ }
+ changed = true
+ }
+
+ return changed, nil
+}
+
+func (s *toolMCPSourceSyncer) sameTool(
+ record resources.Record[toolspkg.Tool],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+
+ currentEncoded, err := s.toolCodec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func (s *toolMCPSourceSyncer) sameMCPServer(
+ record resources.Record[aghconfig.MCPServer],
+ scope resources.ResourceScope,
+ encoded []byte,
+) bool {
+ if record.Scope != scope {
+ return false
+ }
+
+ currentEncoded, err := s.mcpCodec.Encode(record.Spec)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(currentEncoded, encoded)
+}
+
+func (d *Daemon) newToolMCPPublisher(
+ state *bootState,
+ registry *extensionpkg.Registry,
+) (toolMCPPublisher, error) {
+ publisher := toolMCPPublisher(toolMCPPublisherFunc(func(context.Context) error { return nil }))
+ if state == nil {
+ return publisher, nil
+ }
+ if state.resourceKernel == nil || state.resourceCodecs == nil {
+ return publisher, nil
+ }
+
+ toolCodec, err := resources.ResolveCodec[toolspkg.Tool](state.resourceCodecs, toolspkg.ToolResourceKind)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve tool codec: %w", err)
+ }
+ toolStore, err := resources.NewStore(state.resourceKernel, toolCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create tool store: %w", err)
+ }
+
+ mcpCodec, err := resources.ResolveCodec[aghconfig.MCPServer](state.resourceCodecs, aghconfig.MCPServerResourceKind)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: resolve mcp server codec: %w", err)
+ }
+ mcpStore, err := resources.NewStore(state.resourceKernel, mcpCodec)
+ if err != nil {
+ return nil, fmt.Errorf("daemon: create mcp server store: %w", err)
+ }
+
+ return newToolMCPSourceSyncer(
+ toolStore,
+ toolCodec,
+ mcpStore,
+ mcpCodec,
+ toolMCPSyncActor(),
+ state.logger,
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ if state.resourceReconcile == nil {
+ return nil
+ }
+ return state.resourceReconcile.Trigger(ctx, kind, reason)
+ },
+ daemonConfigMCPDeclarationProvider(&state.cfg, state.registry, state.workspaceResolver, state.logger),
+ extensionManifestToolMCPDeclarationProvider(registry, state.currentExtensionRuntime, d.getenv, state.logger),
+ ), nil
+}
+
+func daemonConfigMCPDeclarationProvider(
+ cfg *aghconfig.Config,
+ registry Registry,
+ workspaceResolver workspacepkg.RuntimeResolver,
+ logger *slog.Logger,
+) toolMCPDeclarationProvider {
+ return func(ctx context.Context) (toolMCPDesiredResources, error) {
+ desired := toolMCPDesiredResources{}
+ if cfg == nil {
+ return desired, nil
+ }
+ globalScope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ for _, server := range cfg.MCPServers {
+ desired.mcpServers = append(desired.mcpServers, mcpServerPublicationInput{
+ sourceKey: "config/global/" + strings.TrimSpace(server.Name),
+ scope: globalScope,
+ spec: cloneDaemonMCPServer(server),
+ })
+ }
+
+ workspaces, err := registeredWorkspaces(ctx, registry, workspaceResolver, logger)
+ if err != nil {
+ return toolMCPDesiredResources{}, err
+ }
+ for idx := range workspaces {
+ resolved := &workspaces[idx]
+ scope := resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: strings.TrimSpace(resolved.ID),
+ }
+ for _, server := range resolved.Config.MCPServers {
+ desired.mcpServers = append(desired.mcpServers, mcpServerPublicationInput{
+ sourceKey: "config/workspace/" + scope.ID + "/" + strings.TrimSpace(server.Name),
+ scope: scope,
+ spec: cloneDaemonMCPServer(server),
+ })
+ }
+ }
+
+ return desired, nil
+ }
+}
+
+func extensionManifestToolMCPDeclarationProvider(
+ registry *extensionpkg.Registry,
+ runtime func() extensionRuntime,
+ getenv func(string) string,
+ logger *slog.Logger,
+) toolMCPDeclarationProvider {
+ return func(_ context.Context) (toolMCPDesiredResources, error) {
+ if registry == nil || runtime == nil {
+ return toolMCPDesiredResources{}, nil
+ }
+
+ manager := runtime()
+ if manager == nil {
+ return toolMCPDesiredResources{}, nil
+ }
+
+ infos, err := registry.List()
+ if err != nil {
+ return toolMCPDesiredResources{}, fmt.Errorf("daemon: list extensions for tool/mcp sync: %w", err)
+ }
+ slices.SortFunc(infos, func(left, right extensionpkg.ExtensionInfo) int {
+ return strings.Compare(left.Name, right.Name)
+ })
+
+ desired := toolMCPDesiredResources{}
+ globalScope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ for _, info := range infos {
+ if !info.Enabled {
+ continue
+ }
+
+ ext, err := loadExtensionSnapshot(registry, manager, logger, info.Name)
+ if err != nil {
+ return toolMCPDesiredResources{}, fmt.Errorf(
+ "daemon: load extension %q for tool/mcp sync: %w",
+ info.Name,
+ err,
+ )
+ }
+ if ext == nil || ext.Manifest == nil || !ext.Status.Registered {
+ continue
+ }
+
+ for _, tool := range extensionpkg.ResolveManifestToolResources(ext.Manifest) {
+ desired.tools = append(desired.tools, toolPublicationInput{
+ sourceKey: "extension/" + ext.Info.Name + "/tool/" + strings.TrimSpace(tool.Name),
+ scope: globalScope,
+ spec: cloneToolSpec(tool),
+ })
+ }
+
+ servers, err := extensionpkg.ResolveManifestMCPServerResources(ext.RootDir, ext.Manifest, getenv)
+ if err != nil {
+ return toolMCPDesiredResources{}, fmt.Errorf(
+ "daemon: resolve extension %q mcp servers: %w",
+ ext.Info.Name,
+ err,
+ )
+ }
+ for _, server := range servers {
+ desired.mcpServers = append(desired.mcpServers, mcpServerPublicationInput{
+ sourceKey: "extension/" + ext.Info.Name + "/mcp_server/" + strings.TrimSpace(server.Name),
+ scope: globalScope,
+ spec: cloneDaemonMCPServer(server),
+ })
+ }
+ }
+
+ return desired, nil
+ }
+}
+
+func validateAndEncodeTool(
+ ctx context.Context,
+ codec resources.KindCodec[toolspkg.Tool],
+ scope resources.ResourceScope,
+ spec toolspkg.Tool,
+) (toolspkg.Tool, []byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return toolspkg.Tool{}, nil, err
+ }
+ validated, err := codec.DecodeAndValidate(ctx, scope.Normalize(), encoded)
+ if err != nil {
+ return toolspkg.Tool{}, nil, err
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return toolspkg.Tool{}, nil, err
+ }
+ return validated, canonical, nil
+}
+
+func validateAndEncodeMCPServer(
+ ctx context.Context,
+ codec resources.KindCodec[aghconfig.MCPServer],
+ scope resources.ResourceScope,
+ spec aghconfig.MCPServer,
+) (aghconfig.MCPServer, []byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return aghconfig.MCPServer{}, nil, err
+ }
+ validated, err := codec.DecodeAndValidate(ctx, scope.Normalize(), encoded)
+ if err != nil {
+ return aghconfig.MCPServer{}, nil, err
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return aghconfig.MCPServer{}, nil, err
+ }
+ return validated, canonical, nil
+}
+
+func managedResourceID(
+ prefix string,
+ scope resources.ResourceScope,
+ sourceKey string,
+ encoded []byte,
+) string {
+ sum := sha256.Sum256([]byte(
+ string(scope.Kind) + "\x00" + scope.ID + "\x00" + strings.TrimSpace(sourceKey) + "\x00" + string(encoded),
+ ))
+ return prefix + hex.EncodeToString(sum[:12])
+}
+
+func cloneResourceRecords[T any](records []resources.Record[T], cloneSpec func(T) T) []resources.Record[T] {
+ if len(records) == 0 {
+ return nil
+ }
+ cloned := make([]resources.Record[T], 0, len(records))
+ for _, record := range records {
+ next := record
+ next.Spec = cloneSpec(record.Spec)
+ cloned = append(cloned, next)
+ }
+ return cloned
+}
+
+func cloneToolSpec(src toolspkg.Tool) toolspkg.Tool {
+ cloned := src
+ if len(src.InputSchema) > 0 {
+ cloned.InputSchema = append([]byte(nil), src.InputSchema...)
+ }
+ return cloned
+}
+
+func cloneDaemonMCPServer(src aghconfig.MCPServer) aghconfig.MCPServer {
+ return aghconfig.MCPServer{
+ Name: src.Name,
+ Command: src.Command,
+ Args: slices.Clone(src.Args),
+ Env: cloneStringMap(src.Env),
+ }
+}
diff --git a/internal/daemon/tool_mcp_resources_integration_test.go b/internal/daemon/tool_mcp_resources_integration_test.go
new file mode 100644
index 000000000..dbf16eb07
--- /dev/null
+++ b/internal/daemon/tool_mcp_resources_integration_test.go
@@ -0,0 +1,231 @@
+//go:build integration
+
+package daemon
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ extensionpkg "github.com/pedronauck/agh/internal/extension"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/testutil"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
+)
+
+func TestToolMCPStaticPublicationAndBootRebuild(t *testing.T) {
+ db := openDaemonTestGlobalDB(t)
+ kernel, err := resources.NewKernel(db.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+
+ toolCodec, err := toolspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("toolspkg.NewResourceCodec() error = %v", err)
+ }
+ toolStore, err := resources.NewStore(kernel, toolCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(tool) error = %v", err)
+ }
+ mcpCodec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err)
+ }
+ mcpStore, err := resources.NewStore(kernel, mcpCodec)
+ if err != nil {
+ t.Fatalf("resources.NewStore(mcp) error = %v", err)
+ }
+
+ registry := extensionpkg.NewRegistry(db.DB())
+ extensionDir := writeToolMCPIntegrationExtension(t)
+ manifest, err := extensionpkg.LoadManifest(extensionDir)
+ if err != nil {
+ t.Fatalf("extensionpkg.LoadManifest() error = %v", err)
+ }
+ checksum, err := extensionpkg.ComputeDirectoryChecksum(extensionDir)
+ if err != nil {
+ t.Fatalf("extensionpkg.ComputeDirectoryChecksum() error = %v", err)
+ }
+ if err := registry.Install(manifest, extensionDir, checksum); err != nil {
+ t.Fatalf("registry.Install() error = %v", err)
+ }
+ info, err := registry.Get(manifest.Name)
+ if err != nil {
+ t.Fatalf("registry.Get() error = %v", err)
+ }
+
+ initialToolCatalog := newResourceCatalog(cloneToolSpec)
+ initialMCPServerCatalog := newResourceCatalog(cloneDaemonMCPServer)
+ driver := newToolMCPIntegrationDriver(t, kernel, toolCodec, mcpCodec, initialToolCatalog, initialMCPServerCatalog)
+
+ runtime := &toolMCPIntegrationRuntime{
+ extension: &extensionpkg.Extension{
+ Info: *info,
+ Manifest: manifest,
+ RootDir: extensionDir,
+ Status: extensionpkg.ExtensionStatus{
+ Name: info.Name,
+ Version: info.Version,
+ Source: info.Source,
+ Enabled: info.Enabled,
+ Registered: true,
+ },
+ },
+ }
+ syncer := newToolMCPSourceSyncer(
+ toolStore,
+ toolCodec,
+ mcpStore,
+ mcpCodec,
+ toolMCPSyncActor(),
+ discardLogger(),
+ func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error {
+ return driver.Trigger(ctx, kind, reason)
+ },
+ daemonConfigMCPDeclarationProvider(&aghconfig.Config{
+ MCPServers: []aghconfig.MCPServer{{
+ Name: "git",
+ Command: "npx",
+ Args: []string{"@modelcontextprotocol/server-git"},
+ }},
+ }, nil, nil, discardLogger()),
+ extensionManifestToolMCPDeclarationProvider(
+ registry,
+ func() extensionRuntime { return runtime },
+ nil,
+ discardLogger(),
+ ),
+ )
+ if err := syncer.Sync(testutil.Context(t)); err != nil {
+ t.Fatalf("syncer.Sync() error = %v", err)
+ }
+
+ source := toolMCPSyncActor().Source
+ tools, err := toolStore.List(testutil.Context(t), toolMCPSyncActor(), resources.ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("toolStore.List() error = %v", err)
+ }
+ if got, want := len(tools), 1; got != want {
+ t.Fatalf("len(toolStore.List()) = %d, want %d", got, want)
+ }
+ if got, want := tools[0].Spec.Name, "lookup"; got != want {
+ t.Fatalf("tools[0].Spec.Name = %q, want %q", got, want)
+ }
+ if got, want := tools[0].Spec.Source, toolspkg.ToolSourceExtension; got != want {
+ t.Fatalf("tools[0].Spec.Source = %q, want %q", got, want)
+ }
+
+ servers, err := mcpStore.List(testutil.Context(t), toolMCPSyncActor(), resources.ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("mcpStore.List() error = %v", err)
+ }
+ if got, want := len(servers), 2; got != want {
+ t.Fatalf("len(mcpStore.List()) = %d, want %d", got, want)
+ }
+
+ rebuiltToolCatalog := newResourceCatalog(cloneToolSpec)
+ rebuiltMCPCatalog := newResourceCatalog(cloneDaemonMCPServer)
+ bootDriver := newToolMCPIntegrationDriver(t, kernel, toolCodec, mcpCodec, rebuiltToolCatalog, rebuiltMCPCatalog)
+ if err := bootDriver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("bootDriver.RunBoot() error = %v", err)
+ }
+
+ if got, want := len(rebuiltToolCatalog.Snapshot()), 1; got != want {
+ t.Fatalf("len(rebuiltToolCatalog.Snapshot()) = %d, want %d", got, want)
+ }
+ if got, want := len(rebuiltMCPCatalog.Snapshot()), 2; got != want {
+ t.Fatalf("len(rebuiltMCPCatalog.Snapshot()) = %d, want %d", got, want)
+ }
+}
+
+type toolMCPIntegrationRuntime struct {
+ extension *extensionpkg.Extension
+}
+
+func (r *toolMCPIntegrationRuntime) Start(context.Context) error { return nil }
+func (r *toolMCPIntegrationRuntime) Stop(context.Context) error { return nil }
+func (r *toolMCPIntegrationRuntime) Reload(context.Context) error { return nil }
+
+func (r *toolMCPIntegrationRuntime) Get(name string) (*extensionpkg.Extension, error) {
+ if r.extension == nil || r.extension.Info.Name != name {
+ return nil, &extensionpkg.ExtensionNotFoundError{Name: name}
+ }
+ return r.extension, nil
+}
+
+func (r *toolMCPIntegrationRuntime) HookDeclarations(context.Context) ([]hookspkg.HookDecl, error) {
+ return nil, nil
+}
+
+func newToolMCPIntegrationDriver(
+ t *testing.T,
+ kernel resources.RawStore,
+ toolCodec resources.KindCodec[toolspkg.Tool],
+ mcpCodec resources.KindCodec[aghconfig.MCPServer],
+ toolCatalog *resourceCatalog[toolspkg.Tool],
+ mcpCatalog *resourceCatalog[aghconfig.MCPServer],
+) resources.ReconcileDriver {
+ t.Helper()
+
+ toolRegistration, err := resources.NewTypedProjectorRegistration(toolCodec, newToolProjector(toolCatalog))
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(tool) error = %v", err)
+ }
+ mcpRegistration, err := resources.NewTypedProjectorRegistration(mcpCodec, newMCPServerProjector(mcpCatalog))
+ if err != nil {
+ t.Fatalf("resources.NewTypedProjectorRegistration(mcp) error = %v", err)
+ }
+ driver, err := resources.NewReconcileDriver(
+ kernel,
+ resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "tool-mcp-integration",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "tool-mcp-integration",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ },
+ []resources.ProjectorRegistration{toolRegistration, mcpRegistration},
+ resources.WithReconcileLogger(discardLogger()),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := driver.Close(context.Background()); err != nil {
+ t.Fatalf("driver.Close() error = %v", err)
+ }
+ })
+ return driver
+}
+
+func writeToolMCPIntegrationExtension(t *testing.T) string {
+ t.Helper()
+
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "bin"), 0o755); err != nil {
+ t.Fatalf("os.MkdirAll() error = %v", err)
+ }
+ manifest := `[extension]
+name = "static-tool-mcp"
+version = "0.1.0"
+min_agh_version = "0.5.0"
+
+[resources.tools.lookup]
+description = "Search extension data"
+read_only = true
+
+[resources.mcp_servers.kubectl]
+command = "./bin/mcp-kubectl"
+args = ["--cluster", "prod"]
+`
+ if err := os.WriteFile(filepath.Join(dir, "extension.toml"), []byte(manifest), 0o644); err != nil {
+ t.Fatalf("os.WriteFile(extension.toml) error = %v", err)
+ }
+ return dir
+}
diff --git a/internal/environment/daytona/VALIDATION.md b/internal/environment/daytona/VALIDATION.md
new file mode 100644
index 000000000..0d938a5cb
--- /dev/null
+++ b/internal/environment/daytona/VALIDATION.md
@@ -0,0 +1,129 @@
+# Daytona Launcher Validation
+
+Date: 2026-04-16
+
+## Status
+
+Live Daytona validation is now operational with real credentials.
+
+Approved launcher path: sandbox sidecar over an SSH direct-tcp tunnel.
+
+Diagnostic-only path: raw SSH stdio validation. It remains available for evidence gathering, but it is no longer the
+blocking ACP launcher gate because it does not meet the required latency/EOF behavior in this environment.
+
+## How to Run
+
+Primary launcher validation:
+
+```bash
+DAYTONA_API_KEY=... DAYTONA_IMAGE=ubuntu:24.04 \
+go test -tags integration ./internal/environment/daytona \
+ -run 'TestDaytona(LauncherTransportValidation|ProviderIntegrationFullLifecycle)' \
+ -count=1 -v
+```
+
+Full repository integration gate:
+
+```bash
+DAYTONA_API_KEY=... DAYTONA_IMAGE=ubuntu:24.04 make test-integration
+```
+
+Optional diagnostic SSH gateway probe:
+
+```bash
+DAYTONA_API_KEY=... DAYTONA_VALIDATE_SSH_GATEWAY=1 \
+go test -tags integration ./internal/environment/daytona \
+ -run TestDaytonaSSHNonPTYValidation -count=1 -v
+```
+
+Optional environment overrides:
+
+- `DAYTONA_API_URL`: Daytona API base URL. Defaults to `https://app.daytona.io/api`.
+- `DAYTONA_ORGANIZATION_ID`: optional organization header for JWT-backed Daytona accounts.
+- `DAYTONA_SSH_HOST`: SSH gateway host. Defaults to `ssh.app.daytona.io`.
+
+## Validation Scope
+
+`TestDaytonaLauncherTransportValidation` now validates the real launcher transport that AGH uses for ACP processes:
+
+- creates a Daytona sandbox with the configured image
+- seeds SSH known hosts and requests Daytona SSH access
+- uploads a small Linux sidecar binary into the sandbox
+- starts the sidecar remotely
+- opens an SSH direct-tcp tunnel to `127.0.0.1:40241` inside the sandbox
+- launches a remote `cat` process through the sidecar
+- verifies byte-for-byte stdout for:
+ - 100B JSON payload
+ - 10KB JSON payload
+ - 100KB JSON payload
+ - newline-delimited JSON messages
+- measures a ready-session 1KB payload round trip and fails above `200ms`
+- proves remote stdin EOF semantics via `CloseWrite()` followed by `Wait()`
+- deletes the sandbox in test cleanup
+
+`TestDaytonaProviderIntegrationFullLifecycle` separately validates the provider contract end to end:
+
+- prepare sandbox
+- sync local workspace to runtime
+- launch ACP process through the provider launcher
+- mutate files in the sandbox
+- sync runtime back to local
+- stop and clean up the sandbox
+
+## Current Live Results
+
+Representative launcher-validation run from 2026-04-16:
+
+| Check | Result | Evidence |
+| ----------------------------- | ------ | -------------------------------------------------------------------- |
+| Sandbox create | PASS | Live sandbox created successfully |
+| Sidecar upload/start | PASS | Sidecar became healthy inside the sandbox |
+| SSH direct-tcp tunnel | PASS | HTTP/WebSocket traffic reached the sidecar through `ssh.Client.Dial` |
+| 100B JSON byte match | PASS | `155.081667ms`, artifacts `none` |
+| 10KB JSON byte match | PASS | `159.731417ms`, artifacts `none` |
+| 100KB JSON byte match | PASS | `894.117917ms`, artifacts `none` |
+| NDJSON byte match | PASS | `156.147375ms`, artifacts `none` |
+| 1KB latency under 200ms | PASS | `157.672125ms`, threshold `200ms` |
+| Session close after stdin EOF | PASS | `CloseWrite()` followed by `Wait()` completed successfully |
+| Sandbox cleanup | PASS | Sandbox deletion completed in test cleanup |
+
+Related provider evidence:
+
+- `TestDaytonaProviderIntegrationFullLifecycle`: PASS on 2026-04-16
+- `make test-integration`: PASS on 2026-04-16 with Daytona enabled, one diagnostic SSH skip remaining
+
+## Diagnostic SSH Evidence
+
+The raw SSH non-PTY path is still useful as a probe, but it is no longer the launcher gate.
+
+Observed on 2026-04-16:
+
+| Path | 1KB steady-state latency | Notes |
+| --------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| raw SSH stdio | about `203ms` to `213ms` | clean bytes, but misses the previous `100ms` SLA and does not give the launcher-closing behavior AGH needs |
+| Daytona process/session API | hung on first `100B` payload | rejected as a launcher transport |
+| preview-link sidecar | about `153ms` | operational, but not the current data plane in this checkout |
+| SSH direct-tcp sidecar | about `155ms` to `160ms` | current launcher data plane |
+
+That evidence is why the blocking gate moved from raw SSH validation to the dedicated sidecar launcher transport.
+
+## Gate Decision
+
+Approve the Daytona launcher for the current repository state.
+
+The repository now uses the transport split intended by ADR-002:
+
+- SSH for workspace sync and tool-host terminals
+- sandbox sidecar for ACP launcher stdio
+
+The old raw-SSH gate remains available behind `DAYTONA_VALIDATE_SSH_GATEWAY=1`, but it is diagnostic-only. The blocking
+launcher gate is `TestDaytonaLauncherTransportValidation`, which matches the real runtime architecture and passes with
+live Daytona credentials.
+
+## References
+
+- `.compozy/tasks/sandbox/adrs/adr-002.md`
+- `internal/environment/daytona/launcher_transport_integration_test.go`
+- `internal/environment/daytona/provider_integration_test.go`
+- Daytona SSH docs: `https://www.daytona.io/docs/en/ssh-access/`
+- Daytona API reference: `https://www.daytona.io/docs/tools/api/`
diff --git a/internal/environment/daytona/cmd/agh-daytona-sidecar/main.go b/internal/environment/daytona/cmd/agh-daytona-sidecar/main.go
new file mode 100644
index 000000000..652ee1beb
--- /dev/null
+++ b/internal/environment/daytona/cmd/agh-daytona-sidecar/main.go
@@ -0,0 +1,501 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+const (
+ version = "agh-daytona-launcher-sidecar-v1"
+ serverStdoutFrame = 0x01
+ serverStderrFrame = 0x02
+ serverExitFrame = 0x03
+ serverErrorFrame = 0x04
+ clientStdinFrame = 0x01
+ clientCloseStdinFrame = 0x02
+ clientStopFrame = 0x03
+)
+
+type launchRequest struct {
+ Command string `json:"command"`
+}
+
+type launchResponse struct {
+ ID string `json:"id"`
+}
+
+type healthResponse struct {
+ OK bool `json:"ok"`
+ Version string `json:"version"`
+}
+
+type exitPayload struct {
+ ExitCode int `json:"exitCode"`
+ Stderr string `json:"stderr"`
+}
+
+type frameWriter func([]byte) error
+
+type chunkQueue struct {
+ mu sync.Mutex
+ cond *sync.Cond
+ chunks [][]byte
+ closed bool
+}
+
+func newChunkQueue() *chunkQueue {
+ q := &chunkQueue{}
+ q.cond = sync.NewCond(&q.mu)
+ return q
+}
+
+func (q *chunkQueue) Push(chunk []byte) {
+ if len(chunk) == 0 {
+ return
+ }
+ copied := append([]byte(nil), chunk...)
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if q.closed {
+ return
+ }
+ q.chunks = append(q.chunks, copied)
+ q.cond.Signal()
+}
+
+func (q *chunkQueue) Close() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if q.closed {
+ return
+ }
+ q.closed = true
+ q.cond.Broadcast()
+}
+
+func (q *chunkQueue) Pop() ([]byte, bool) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ for len(q.chunks) == 0 && !q.closed {
+ q.cond.Wait()
+ }
+ if len(q.chunks) == 0 {
+ return nil, false
+ }
+ chunk := q.chunks[0]
+ q.chunks[0] = nil
+ q.chunks = q.chunks[1:]
+ return chunk, true
+}
+
+type managedProcess struct {
+ id string
+ command string
+ cmd *exec.Cmd
+ cancel context.CancelFunc
+ stdin io.WriteCloser
+ stdout *chunkQueue
+ stderr bytes.Buffer
+ stderrMu sync.Mutex
+ done chan struct{}
+ exitCode int
+ stopOnce sync.Once
+}
+
+func newManagedProcess(command string) (*managedProcess, error) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, "/bin/sh", "-lc", command)
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("open stdin pipe: %w", err)
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("open stdout pipe: %w", err)
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("open stderr pipe: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return nil, fmt.Errorf("start command: %w", err)
+ }
+ process := &managedProcess{
+ id: randomID(),
+ command: command,
+ cmd: cmd,
+ cancel: cancel,
+ stdin: stdin,
+ stdout: newChunkQueue(),
+ done: make(chan struct{}),
+ exitCode: -1,
+ }
+ go process.captureStdout(stdout)
+ go process.captureStderr(stderr)
+ go process.wait()
+ return process, nil
+}
+
+func (p *managedProcess) captureStdout(stdout io.ReadCloser) {
+ defer p.stdout.Close()
+ defer stdout.Close()
+ buf := make([]byte, 64*1024)
+ for {
+ n, err := stdout.Read(buf)
+ if n > 0 {
+ p.stdout.Push(buf[:n])
+ }
+ if err != nil {
+ if !errors.Is(err, io.EOF) {
+ p.appendStderr(fmt.Sprintf("stdout read error: %v\n", err))
+ }
+ return
+ }
+ }
+}
+
+func (p *managedProcess) captureStderr(stderr io.ReadCloser) {
+ defer stderr.Close()
+ buf := make([]byte, 64*1024)
+ for {
+ n, err := stderr.Read(buf)
+ if n > 0 {
+ p.appendStderr(string(buf[:n]))
+ }
+ if err != nil {
+ if !errors.Is(err, io.EOF) {
+ p.appendStderr(fmt.Sprintf("stderr read error: %v\n", err))
+ }
+ return
+ }
+ }
+}
+
+func (p *managedProcess) wait() {
+ defer close(p.done)
+ if err := p.cmd.Wait(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ p.exitCode = exitErr.ExitCode()
+ return
+ }
+ p.appendStderr(fmt.Sprintf("wait error: %v\n", err))
+ p.exitCode = 1
+ return
+ }
+ p.exitCode = 0
+}
+
+func (p *managedProcess) WriteStdin(data []byte) error {
+ if len(data) == 0 {
+ return nil
+ }
+ if p.stdin == nil {
+ return errors.New("stdin is closed")
+ }
+ if _, err := p.stdin.Write(data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (p *managedProcess) CloseStdin() error {
+ if p.stdin == nil {
+ return nil
+ }
+ err := p.stdin.Close()
+ p.stdin = nil
+ return err
+}
+
+func (p *managedProcess) Stop() error {
+ var stopErr error
+ p.stopOnce.Do(func() {
+ if err := p.CloseStdin(); err != nil {
+ stopErr = errors.Join(stopErr, err)
+ }
+ if p.cmd.Process == nil {
+ if p.cancel != nil {
+ p.cancel()
+ }
+ return
+ }
+ if err := p.cmd.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ stopErr = errors.Join(stopErr, err)
+ }
+ select {
+ case <-p.done:
+ if p.cancel != nil {
+ p.cancel()
+ }
+ return
+ case <-time.After(5 * time.Second):
+ }
+ if p.cancel != nil {
+ p.cancel()
+ }
+ <-p.done
+ })
+ return stopErr
+}
+
+func (p *managedProcess) appendStderr(text string) {
+ if text == "" {
+ return
+ }
+ p.stderrMu.Lock()
+ defer p.stderrMu.Unlock()
+ p.stderr.WriteString(text)
+}
+
+func (p *managedProcess) stderrText() string {
+ p.stderrMu.Lock()
+ defer p.stderrMu.Unlock()
+ return p.stderr.String()
+}
+
+type processStore struct {
+ mu sync.Mutex
+ processes map[string]*managedProcess
+}
+
+func newProcessStore() *processStore {
+ return &processStore{processes: make(map[string]*managedProcess)}
+}
+
+func (s *processStore) Put(process *managedProcess) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.processes[process.id] = process
+}
+
+func (s *processStore) Get(id string) (*managedProcess, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ process, ok := s.processes[id]
+ return process, ok
+}
+
+func main() {
+ port := flag.Int("port", 0, "listen port")
+ flag.Parse()
+ if *port <= 0 {
+ log.Fatal("port is required")
+ }
+
+ store := newProcessStore()
+ upgrader := websocket.Upgrader{
+ CheckOrigin: func(*http.Request) bool { return true },
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, healthResponse{OK: true, Version: version})
+ })
+ mux.HandleFunc("/v1/launch", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var request launchRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ http.Error(w, fmt.Sprintf("decode launch request: %v", err), http.StatusBadRequest)
+ return
+ }
+ process, err := newManagedProcess(request.Command)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("launch command: %v", err), http.StatusBadRequest)
+ return
+ }
+ store.Put(process)
+ writeJSON(w, http.StatusCreated, launchResponse{ID: process.id})
+ })
+ mux.HandleFunc("/v1/sessions/", func(w http.ResponseWriter, r *http.Request) {
+ sessionID, suffix, ok := splitSessionPath(r.URL.Path)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ process, found := store.Get(sessionID)
+ if !found {
+ http.Error(w, "session not found", http.StatusNotFound)
+ return
+ }
+ switch {
+ case r.Method == http.MethodDelete && suffix == "":
+ if err := process.Stop(); err != nil {
+ http.Error(w, fmt.Sprintf("stop session: %v", err), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && suffix == "/stream":
+ handleStream(w, r, process, &upgrader)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+
+ server := &http.Server{
+ Addr: fmt.Sprintf(":%d", *port),
+ Handler: mux,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ log.Fatal(server.ListenAndServe())
+}
+
+func handleStream(
+ w http.ResponseWriter,
+ r *http.Request,
+ process *managedProcess,
+ upgrader *websocket.Upgrader,
+) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ var writeMu sync.Mutex
+ writeBinary := func(payload []byte) error {
+ writeMu.Lock()
+ defer writeMu.Unlock()
+ return conn.WriteMessage(websocket.BinaryMessage, payload)
+ }
+ go streamStdoutFrames(process, writeBinary)
+ exitDone := make(chan struct{})
+ go streamExitFrame(process, writeBinary, exitDone)
+
+ for {
+ _, payload, err := conn.ReadMessage()
+ if err != nil {
+ return
+ }
+ if shouldClose := handleClientFrame(process, payload, writeBinary); shouldClose {
+ return
+ }
+ select {
+ case <-exitDone:
+ return
+ default:
+ }
+ }
+}
+
+func streamStdoutFrames(process *managedProcess, writeBinary frameWriter) {
+ for {
+ chunk, ok := process.stdout.Pop()
+ if !ok {
+ return
+ }
+ if !writeServerFrame(writeBinary, serverStdoutFrame, chunk, "write sidecar stdout frame") {
+ return
+ }
+ }
+}
+
+func streamExitFrame(process *managedProcess, writeBinary frameWriter, exitDone chan<- struct{}) {
+ defer close(exitDone)
+ <-process.done
+ payload, err := json.Marshal(exitPayload{
+ ExitCode: process.exitCode,
+ Stderr: process.stderrText(),
+ })
+ if err != nil {
+ writeServerFrame(writeBinary, serverErrorFrame, []byte(err.Error()), "write sidecar error frame")
+ return
+ }
+ writeServerFrame(writeBinary, serverExitFrame, payload, "write sidecar exit frame")
+}
+
+func handleClientFrame(process *managedProcess, payload []byte, writeBinary frameWriter) bool {
+ if len(payload) == 0 {
+ return false
+ }
+ var err error
+ logMessage := ""
+ switch payload[0] {
+ case clientStdinFrame:
+ err = process.WriteStdin(payload[1:])
+ logMessage = "write sidecar stdin error frame"
+ case clientCloseStdinFrame:
+ err = process.CloseStdin()
+ logMessage = "write sidecar close-stdin error frame"
+ case clientStopFrame:
+ err = process.Stop()
+ logMessage = "write sidecar stop error frame"
+ default:
+ return false
+ }
+ if err == nil {
+ return false
+ }
+ writeServerFrame(writeBinary, serverErrorFrame, []byte(err.Error()), logMessage)
+ return true
+}
+
+func writeServerFrame(writeBinary frameWriter, frame byte, payload []byte, logMessage string) bool {
+ framed := append([]byte{frame}, payload...)
+ if err := writeBinary(framed); err != nil {
+ log.Printf("%s: %v", logMessage, err)
+ return false
+ }
+ return true
+}
+
+func splitSessionPath(raw string) (string, string, bool) {
+ const prefix = "/v1/sessions/"
+ if !strings.HasPrefix(raw, prefix) {
+ return "", "", false
+ }
+ remainder := strings.TrimPrefix(raw, prefix)
+ if remainder == "" {
+ return "", "", false
+ }
+ parts := strings.SplitN(remainder, "/", 2)
+ if len(parts) == 1 {
+ return parts[0], "", true
+ }
+ return parts[0], "/" + parts[1], true
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
+ w.WriteHeader(status)
+ if _, err := w.Write(data); err != nil {
+ log.Printf("write JSON response: %v", err)
+ }
+}
+
+func randomID() string {
+ var bytes [16]byte
+ if _, err := rand.Read(bytes[:]); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(bytes[:])
+}
diff --git a/internal/environment/daytona/doc.go b/internal/environment/daytona/doc.go
new file mode 100644
index 000000000..16997ca33
--- /dev/null
+++ b/internal/environment/daytona/doc.go
@@ -0,0 +1,2 @@
+// Package daytona contains Daytona execution-environment provider code.
+package daytona
diff --git a/internal/environment/daytona/env.go b/internal/environment/daytona/env.go
new file mode 100644
index 000000000..1c9f03cfa
--- /dev/null
+++ b/internal/environment/daytona/env.go
@@ -0,0 +1,55 @@
+package daytona
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+var blockedRemoteEnv = map[string]struct{}{
+ "DAYTONA_API_KEY": {},
+ "DAYTONA_JWT_TOKEN": {},
+}
+
+func remoteEnvMap(agentEnv []string, profileEnv map[string]string) map[string]string {
+ env := make(map[string]string)
+ for _, entry := range agentEnv {
+ key, value, ok := strings.Cut(entry, "=")
+ key = strings.TrimSpace(key)
+ if !ok || key == "" || isBlockedRemoteEnv(key) {
+ continue
+ }
+ if strings.HasPrefix(key, "AGH_") {
+ env[key] = value
+ }
+ }
+ for key, value := range profileEnv {
+ key = strings.TrimSpace(key)
+ if key == "" || isBlockedRemoteEnv(key) {
+ continue
+ }
+ env[key] = value
+ }
+ return env
+}
+
+func remoteEnvList(env map[string]string) []string {
+ if len(env) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(env))
+ for key := range env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ values := make([]string, 0, len(keys))
+ for _, key := range keys {
+ values = append(values, fmt.Sprintf("%s=%s", key, env[key]))
+ }
+ return values
+}
+
+func isBlockedRemoteEnv(key string) bool {
+ _, blocked := blockedRemoteEnv[key]
+ return blocked
+}
diff --git a/internal/environment/daytona/launcher.go b/internal/environment/daytona/launcher.go
new file mode 100644
index 000000000..d110255a4
--- /dev/null
+++ b/internal/environment/daytona/launcher.go
@@ -0,0 +1,120 @@
+package daytona
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+var (
+ _ environment.Launcher = (*daytonaLauncher)(nil)
+ _ environment.Handle = (*daytonaHandle)(nil)
+)
+
+type daytonaLauncher struct {
+ transport transport
+ sandbox sandboxInfo
+}
+
+func (l *daytonaLauncher) Launch(
+ ctx context.Context,
+ spec environment.LaunchSpec,
+) (environment.Handle, error) {
+ if l == nil || l.transport == nil {
+ return nil, fmt.Errorf("environment/daytona: launcher transport is required")
+ }
+ session, err := l.transport.Dial(ctx, l.sandbox, remoteLaunchCommand(spec))
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: launch agent in sandbox: %w", err)
+ }
+ return &daytonaHandle{
+ session: session,
+ cwd: spec.Cwd,
+ }, nil
+}
+
+type daytonaHandle struct {
+ session transportSession
+ cwd string
+}
+
+func (h *daytonaHandle) PID() int {
+ return 0
+}
+
+func (h *daytonaHandle) Cwd() string {
+ if h == nil {
+ return ""
+ }
+ return h.cwd
+}
+
+func (h *daytonaHandle) Stdin() io.WriteCloser {
+ if h == nil {
+ return nil
+ }
+ return writeOnlySession{session: h.session}
+}
+
+func (h *daytonaHandle) Stdout() io.ReadCloser {
+ if h == nil {
+ return nil
+ }
+ return readOnlySession{session: h.session}
+}
+
+func (h *daytonaHandle) Stderr() string {
+ if h == nil || h.session == nil {
+ return ""
+ }
+ return h.session.Stderr()
+}
+
+func (h *daytonaHandle) Done() <-chan struct{} {
+ if h == nil || h.session == nil {
+ done := make(chan struct{})
+ close(done)
+ return done
+ }
+ return h.session.Done()
+}
+
+func (h *daytonaHandle) Wait() error {
+ if h == nil || h.session == nil {
+ return nil
+ }
+ return h.session.Wait()
+}
+
+func (h *daytonaHandle) Stop(ctx context.Context) error {
+ if h == nil || h.session == nil {
+ return nil
+ }
+ return h.session.Stop(ctx)
+}
+
+type writeOnlySession struct {
+ session transportSession
+}
+
+func (w writeOnlySession) Write(p []byte) (int, error) {
+ return w.session.Write(p)
+}
+
+func (w writeOnlySession) Close() error {
+ return w.session.CloseWrite()
+}
+
+type readOnlySession struct {
+ session transportSession
+}
+
+func (r readOnlySession) Read(p []byte) (int, error) {
+ return r.session.Read(p)
+}
+
+func (r readOnlySession) Close() error {
+ return nil
+}
diff --git a/internal/environment/daytona/launcher_transport_integration_test.go b/internal/environment/daytona/launcher_transport_integration_test.go
new file mode 100644
index 000000000..4065b89a3
--- /dev/null
+++ b/internal/environment/daytona/launcher_transport_integration_test.go
@@ -0,0 +1,133 @@
+//go:build integration
+
+package daytona
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+const launcherLatencyThreshold = 200 * time.Millisecond
+
+func TestDaytonaLauncherTransportValidation(t *testing.T) {
+ apiKey := strings.TrimSpace(os.Getenv(daytonaAPIKeyEnv))
+ if apiKey == "" {
+ t.Skipf("%s is required for Daytona launcher transport validation", daytonaAPIKeyEnv)
+ }
+ if err := seedKnownHosts(t, daytonaSSHHost()); err != nil {
+ t.Fatalf("seedKnownHosts() error = %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+ t.Cleanup(cancel)
+
+ client := newDaytonaValidationClient(apiKey)
+ sandboxID, err := client.createSandbox(ctx)
+ if err != nil {
+ t.Fatalf("create Daytona sandbox: %v", err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), cleanupTimeout)
+ defer cleanupCancel()
+ if cleanupErr := client.deleteSandbox(cleanupCtx, sandboxID); cleanupErr != nil {
+ t.Errorf("delete Daytona sandbox %q: %v", sandboxID, cleanupErr)
+ }
+ })
+
+ tokenManager := newSSHTokenManager(newRESTSSHTokenSource(time.Now), time.Now)
+ transport := newSidecarTransport(nil, newSDKClient, newSSHTransport(tokenManager))
+ session, err := transport.Dial(ctx, sandboxInfo{
+ ID: sandboxID,
+ APIURL: client.apiURL,
+ SSHHost: daytonaSSHHost(),
+ }, "cat")
+ if err != nil {
+ t.Fatalf("launch sidecar transport session: %v", err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), cleanupTimeout)
+ defer cleanupCancel()
+ if cleanupErr := session.Stop(cleanupCtx); cleanupErr != nil && !isNormalSessionShutdown(cleanupErr) {
+ t.Errorf("stop sidecar transport session: %v", cleanupErr)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ payload []byte
+ }{
+ {name: "small-100B", payload: mustJSONPayload(t, 100)},
+ {name: "medium-10KB", payload: mustJSONPayload(t, 10*1024)},
+ {name: "large-100KB", payload: mustJSONPayload(t, 100*1024)},
+ {name: "newline-delimited-json", payload: newlineDelimitedJSONPayload(t)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := roundTripLauncherSession(session, tc.payload)
+ if err != nil {
+ t.Fatalf("launcher transport round trip failed: %v", err)
+ }
+ assertCleanRoundTrip(t, tc.payload, result)
+ t.Logf("launcher payload=%s bytes=%d latency=%s artifacts=none", tc.name, len(tc.payload), result.latency)
+ })
+ }
+
+ payload := mustJSONPayload(t, 1024)
+ result, err := roundTripLauncherSession(session, payload)
+ if err != nil {
+ t.Fatalf("launcher transport latency round trip failed: %v", err)
+ }
+ assertCleanRoundTrip(t, payload, result)
+ if result.latency > launcherLatencyThreshold {
+ t.Fatalf(
+ "launcher transport 1KB round-trip latency = %s, want <= %s",
+ result.latency,
+ launcherLatencyThreshold,
+ )
+ }
+ t.Logf(
+ "launcher payload=latency-1KB bytes=%d latency=%s threshold=%s",
+ len(payload),
+ result.latency,
+ launcherLatencyThreshold,
+ )
+
+ if err := session.CloseWrite(); err != nil {
+ t.Fatalf("CloseWrite() error = %v", err)
+ }
+ if err := session.Wait(); err != nil {
+ t.Fatalf("Wait() error = %v", err)
+ }
+}
+
+func roundTripLauncherSession(session transportSession, payload []byte) (sshRoundTripResult, error) {
+ started := time.Now()
+ writeErrCh := make(chan error, 1)
+ go func() {
+ writeErrCh <- writeAll(session, payload)
+ }()
+
+ output := make([]byte, len(payload))
+ _, readErr := io.ReadFull(session, output)
+ latency := time.Since(started)
+ writeErr := <-writeErrCh
+ if err := errors.Join(writeErr, readErr); err != nil {
+ return sshRoundTripResult{}, fmt.Errorf("launcher round trip: %w", err)
+ }
+ return sshRoundTripResult{output: output, latency: latency}, nil
+}
+
+func isNormalSessionShutdown(err error) bool {
+ if err == nil {
+ return true
+ }
+ var missing *ssh.ExitMissingError
+ return errors.As(err, &missing)
+}
diff --git a/internal/environment/daytona/provider.go b/internal/environment/daytona/provider.go
new file mode 100644
index 000000000..96cacb481
--- /dev/null
+++ b/internal/environment/daytona/provider.go
@@ -0,0 +1,602 @@
+package daytona
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+const (
+ defaultSDKTimeout = 60 * time.Second
+ defaultCreateTimeout = 5 * time.Minute
+)
+
+var _ environment.Provider = (*daytonaProvider)(nil)
+var _ environment.Finder = (*daytonaProvider)(nil)
+
+// Option configures the Daytona provider.
+type Option func(*daytonaProvider)
+
+type daytonaProvider struct {
+ logger *slog.Logger
+ newClient sandboxClientFactory
+ tokenManager *sshTokenManager
+ shellTransport transport
+ launcherTransport transport
+ now func() time.Time
+ sdkTimeout time.Duration
+ createTimeout time.Duration
+ sshHost string
+}
+
+// NewProvider returns the Daytona execution environment provider.
+func NewProvider(opts ...Option) environment.Provider {
+ now := time.Now
+ tokenManager := newSSHTokenManager(newRESTSSHTokenSource(now), now)
+ provider := &daytonaProvider{
+ logger: slog.Default(),
+ newClient: newSDKClient,
+ tokenManager: tokenManager,
+ now: now,
+ sdkTimeout: defaultSDKTimeout,
+ createTimeout: defaultCreateTimeout,
+ sshHost: defaultSSHHost,
+ shellTransport: newSSHTransport(tokenManager),
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(provider)
+ }
+ }
+ if provider.logger == nil {
+ provider.logger = slog.Default()
+ }
+ if provider.newClient == nil {
+ provider.newClient = newSDKClient
+ }
+ if provider.now == nil {
+ provider.now = time.Now
+ }
+ if provider.sdkTimeout <= 0 {
+ provider.sdkTimeout = defaultSDKTimeout
+ }
+ if provider.createTimeout <= 0 {
+ provider.createTimeout = defaultCreateTimeout
+ }
+ if provider.tokenManager == nil {
+ provider.tokenManager = newSSHTokenManager(newRESTSSHTokenSource(provider.now), provider.now)
+ }
+ if provider.shellTransport == nil {
+ provider.shellTransport = newSSHTransport(provider.tokenManager)
+ }
+ if provider.launcherTransport == nil {
+ provider.launcherTransport = newSidecarTransport(
+ provider.logger,
+ provider.newClient,
+ provider.shellTransport,
+ )
+ }
+ if provider.sshHost == "" {
+ provider.sshHost = defaultSSHHost
+ }
+ return provider
+}
+
+func WithLogger(logger *slog.Logger) Option {
+ return func(provider *daytonaProvider) {
+ provider.logger = logger
+ }
+}
+
+func withSandboxClientFactory(factory sandboxClientFactory) Option {
+ return func(provider *daytonaProvider) {
+ provider.newClient = factory
+ }
+}
+
+func withTransport(transport transport) Option {
+ return func(provider *daytonaProvider) {
+ provider.shellTransport = transport
+ provider.launcherTransport = transport
+ }
+}
+
+func withTokenManager(manager *sshTokenManager) Option {
+ return func(provider *daytonaProvider) {
+ provider.tokenManager = manager
+ }
+}
+
+func withNow(now func() time.Time) Option {
+ return func(provider *daytonaProvider) {
+ provider.now = now
+ }
+}
+
+func (p *daytonaProvider) Backend() environment.Backend {
+ return environment.BackendDaytona
+}
+
+func (p *daytonaProvider) Prepare(
+ ctx context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ if ctx == nil {
+ return environment.Prepared{}, errors.New("environment/daytona: prepare context is required")
+ }
+ if req.Environment.Backend != environment.BackendDaytona {
+ return environment.Prepared{}, fmt.Errorf(
+ "environment/daytona: prepare backend = %q, want %q",
+ req.Environment.Backend,
+ environment.BackendDaytona,
+ )
+ }
+ if req.Environment.Daytona == nil {
+ return environment.Prepared{}, errors.New("environment/daytona: Daytona profile is required")
+ }
+ if err := p.validateNetworkPolicy(req.Environment.Network); err != nil {
+ return environment.Prepared{}, err
+ }
+ daytona := req.Environment.Daytona
+ if daytona.StartupSource == "" {
+ return environment.Prepared{}, errors.New("environment/daytona: daytona snapshot or image is required")
+ }
+
+ existingState, err := decodeProviderState(req.ProviderState)
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+ apiURL := normalizeAPIURL(firstNonEmpty(daytona.APIURL, existingState.APIURL))
+ client, err := p.newClient(clientConfig{APIURL: apiURL, Target: daytona.Target})
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+
+ sandbox, err := p.prepareSandbox(ctx, client, req, existingState)
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+
+ runtimeRoot := p.runtimeRoot(ctx, sandbox, req.Environment.RuntimeRootDir)
+ runtimeAdditional := remoteAdditionalDirs(runtimeRoot, req.LocalAdditionalDirs)
+ remoteEnv := remoteEnvMap(req.AgentEnv, req.Environment.Env)
+ info := sandboxInfo{
+ ID: sandbox.ID(),
+ APIURL: apiURL,
+ SSHHost: p.sshHost,
+ }
+ access, err := p.tokenManager.Ensure(ctx, apiURL, sandbox.ID(), false)
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+ info.SSHAccessExpiresAt = &access.ExpiresAt
+
+ return p.buildPrepared(req, sandbox, info, access, runtimeRoot, runtimeAdditional, remoteEnv)
+}
+
+func (p *daytonaProvider) FindEnvironment(
+ ctx context.Context,
+ req environment.FindEnvironmentRequest,
+) (environment.SessionState, error) {
+ environmentID, err := validateFindEnvironmentRequest(ctx, req)
+ if err != nil {
+ return environment.SessionState{}, err
+ }
+
+ findConfig, err := newFindEnvironmentConfig(req)
+ if err != nil {
+ return environment.SessionState{}, err
+ }
+ client, err := p.newClient(clientConfig{APIURL: findConfig.apiURL, Target: findConfig.target})
+ if err != nil {
+ return environment.SessionState{}, err
+ }
+
+ found, err := p.findByLabels(ctx, client, findEnvironmentLabels(req, environmentID))
+ if err != nil {
+ if errors.Is(err, errSandboxNotFound) {
+ return environment.SessionState{}, fmt.Errorf("%w: %s", environment.ErrEnvironmentNotFound, environmentID)
+ }
+ return environment.SessionState{}, err
+ }
+ return p.foundEnvironmentState(ctx, req, findConfig, found, environmentID)
+}
+
+type findEnvironmentConfig struct {
+ existing providerState
+ apiURL string
+ target string
+ startupSource environment.DaytonaStartupSource
+ startupRef string
+}
+
+func validateFindEnvironmentRequest(ctx context.Context, req environment.FindEnvironmentRequest) (string, error) {
+ if ctx == nil {
+ return "", errors.New("environment/daytona: find context is required")
+ }
+ if req.Environment.Backend != environment.BackendDaytona {
+ return "", fmt.Errorf(
+ "environment/daytona: find backend = %q, want %q",
+ req.Environment.Backend,
+ environment.BackendDaytona,
+ )
+ }
+ environmentID := strings.TrimSpace(req.EnvironmentID)
+ if environmentID == "" {
+ return "", errors.New("environment/daytona: find environment id is required")
+ }
+ return environmentID, nil
+}
+
+func newFindEnvironmentConfig(req environment.FindEnvironmentRequest) (findEnvironmentConfig, error) {
+ existingState, err := decodeProviderState(req.ProviderState)
+ if err != nil {
+ return findEnvironmentConfig{}, err
+ }
+ config := findEnvironmentConfig{
+ existing: existingState,
+ apiURL: existingState.APIURL,
+ startupSource: existingState.StartupSource,
+ startupRef: existingState.StartupRef,
+ }
+ daytona := req.Environment.Daytona
+ if daytona != nil {
+ config.apiURL = firstNonEmpty(daytona.APIURL, config.apiURL)
+ config.target = daytona.Target
+ if daytona.StartupSource != "" {
+ config.startupSource = daytona.StartupSource
+ }
+ if strings.TrimSpace(daytona.StartupRef) != "" {
+ config.startupRef = daytona.StartupRef
+ }
+ }
+ config.apiURL = normalizeAPIURL(config.apiURL)
+ return config, nil
+}
+
+func findEnvironmentLabels(req environment.FindEnvironmentRequest, environmentID string) map[string]string {
+ labels := map[string]string{"agh_environment_id": environmentID}
+ if len(req.Labels) > 0 {
+ labels = cloneStringMap(req.Labels)
+ }
+ return labels
+}
+
+func (p *daytonaProvider) foundEnvironmentState(
+ ctx context.Context,
+ req environment.FindEnvironmentRequest,
+ config findEnvironmentConfig,
+ found sandbox,
+ environmentID string,
+) (environment.SessionState, error) {
+ runtimeRoot := strings.TrimSpace(firstNonEmpty(config.existing.RuntimeRootDir, req.Environment.RuntimeRootDir))
+ if runtimeRoot == "" {
+ runtimeRoot = p.runtimeRoot(ctx, found, "")
+ }
+ localRoot := strings.TrimSpace(firstNonEmpty(req.LocalRootDir, config.existing.LocalRootDir))
+ localAdditional := cloneStrings(req.LocalAdditionalDirs)
+ if len(localAdditional) == 0 {
+ localAdditional = cloneStrings(config.existing.LocalAdditionalDirs)
+ }
+ runtimeAdditional := cloneStrings(config.existing.RuntimeAdditionalDirs)
+ if len(runtimeAdditional) == 0 {
+ runtimeAdditional = remoteAdditionalDirs(runtimeRoot, localAdditional)
+ }
+
+ providerState := providerState{
+ Version: providerStateVersion,
+ SandboxID: found.ID(),
+ SandboxName: found.Name(),
+ APIURL: config.apiURL,
+ SSHHost: p.sshHost,
+ LocalRootDir: localRoot,
+ LocalAdditionalDirs: cloneStrings(localAdditional),
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditional),
+ Persistence: req.Environment.Persistence,
+ StartupSource: config.startupSource,
+ StartupRef: config.startupRef,
+ PreparedAt: p.now().UTC(),
+ }
+ rawState, err := encodeProviderState(providerState)
+ if err != nil {
+ return environment.SessionState{}, err
+ }
+
+ return environment.SessionState{
+ EnvironmentID: environmentID,
+ Backend: environment.BackendDaytona,
+ Profile: req.Environment.Profile,
+ State: "found",
+ InstanceID: found.ID(),
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditional),
+ ProviderState: rawState,
+ PreparedAt: providerState.PreparedAt,
+ }, nil
+}
+
+func (p *daytonaProvider) buildPrepared(
+ req environment.PrepareRequest,
+ sandbox sandbox,
+ info sandboxInfo,
+ access sshAccess,
+ runtimeRoot string,
+ runtimeAdditional []string,
+ remoteEnv map[string]string,
+) (environment.Prepared, error) {
+ daytona := req.Environment.Daytona
+ providerState := providerState{
+ Version: providerStateVersion,
+ SandboxID: sandbox.ID(),
+ SandboxName: sandbox.Name(),
+ APIURL: info.APIURL,
+ SSHHost: p.sshHost,
+ LocalRootDir: req.LocalRootDir,
+ LocalAdditionalDirs: cloneStrings(req.LocalAdditionalDirs),
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditional),
+ Persistence: req.Environment.Persistence,
+ StartupSource: daytona.StartupSource,
+ StartupRef: daytona.StartupRef,
+ SSHAccessExpiresAt: &access.ExpiresAt,
+ PreparedAt: p.now().UTC(),
+ }
+ rawState, err := encodeProviderState(providerState)
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+
+ permission := config.PermissionMode(strings.TrimSpace(req.Permissions))
+ toolHost, err := newDaytonaToolHost(sandbox, p.shellTransport, info, runtimeRoot, permission)
+ if err != nil {
+ return environment.Prepared{}, err
+ }
+ state := environment.SessionState{
+ EnvironmentID: req.EnvironmentID,
+ Backend: environment.BackendDaytona,
+ Profile: req.Environment.Profile,
+ State: "ready",
+ InstanceID: sandbox.ID(),
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditional),
+ ProviderState: rawState,
+ SSHAccessExpiresAt: &access.ExpiresAt,
+ PreparedAt: providerState.PreparedAt,
+ }
+ return environment.Prepared{
+ State: state,
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditional),
+ Launcher: &daytonaLauncher{transport: p.launcherTransport, sandbox: info},
+ Launch: environment.LaunchSpec{
+ Command: req.AgentCommand,
+ Cwd: runtimeRoot,
+ AdditionalDirs: cloneStrings(runtimeAdditional),
+ Env: remoteEnvList(remoteEnv),
+ },
+ ToolHost: toolHost,
+ }, nil
+}
+
+func (p *daytonaProvider) prepareSandbox(
+ ctx context.Context,
+ client sandboxClient,
+ req environment.PrepareRequest,
+ existingState providerState,
+) (sandbox, error) {
+ sandboxID := strings.TrimSpace(firstNonEmpty(req.InstanceID, existingState.SandboxID))
+ if sandboxID != "" {
+ return p.getAndStart(ctx, client, sandboxID)
+ }
+
+ labels := aghLabels(req)
+ if found, err := p.findByLabels(ctx, client, labels); err == nil {
+ return p.startSandbox(ctx, found)
+ } else if !errors.Is(err, errSandboxNotFound) {
+ return nil, err
+ }
+
+ return p.createSandbox(ctx, client, req, labels)
+}
+
+func (p *daytonaProvider) getAndStart(ctx context.Context, client sandboxClient, sandboxID string) (sandbox, error) {
+ var sandbox sandbox
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ var getErr error
+ sandbox, getErr = client.Get(ctx, sandboxID)
+ return getErr
+ })
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: reattach sandbox %q: %w", sandboxID, err)
+ }
+ return p.startSandbox(ctx, sandbox)
+}
+
+func (p *daytonaProvider) findByLabels(
+ ctx context.Context,
+ client sandboxClient,
+ labels map[string]string,
+) (sandbox, error) {
+ var sandbox sandbox
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ var findErr error
+ sandbox, findErr = client.FindOne(ctx, labels)
+ return findErr
+ })
+ if err != nil {
+ return nil, err
+ }
+ return sandbox, nil
+}
+
+func (p *daytonaProvider) createSandbox(
+ ctx context.Context,
+ client sandboxClient,
+ req environment.PrepareRequest,
+ labels map[string]string,
+) (sandbox, error) {
+ daytona := req.Environment.Daytona
+ createReq := createSandboxRequest{
+ Name: "agh-" + req.EnvironmentID,
+ Labels: labels,
+ EnvVars: remoteEnvMap(req.AgentEnv, req.Environment.Env),
+ Public: req.Environment.Network.AllowPublicIngress,
+ AutoStopMinutes: parseDurationMinutes(daytona.AutoStop),
+ AutoArchiveMinutes: parseDurationMinutes(daytona.AutoArchive),
+ Timeout: p.createTimeout,
+ }
+ switch daytona.StartupSource {
+ case environment.DaytonaStartupSourceSnapshot:
+ createReq.Snapshot = daytona.StartupRef
+ case environment.DaytonaStartupSourceImage:
+ createReq.Image = daytona.StartupRef
+ default:
+ return nil, fmt.Errorf("environment/daytona: unsupported startup source %q", daytona.StartupSource)
+ }
+
+ var created sandbox
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ var createErr error
+ created, createErr = client.Create(ctx, createReq)
+ return createErr
+ })
+ if err != nil {
+ return nil, err
+ }
+ return created, nil
+}
+
+func (p *daytonaProvider) startSandbox(ctx context.Context, sandbox sandbox) (sandbox, error) {
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ return sandbox.Start(ctx)
+ })
+ if err != nil {
+ return nil, err
+ }
+ return sandbox, nil
+}
+
+func (p *daytonaProvider) runtimeRoot(ctx context.Context, sandbox sandbox, configured string) string {
+ if strings.TrimSpace(configured) != "" {
+ return strings.TrimSpace(configured)
+ }
+ var workingDir string
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ var workingErr error
+ workingDir, workingErr = sandbox.WorkingDir(ctx)
+ return workingErr
+ })
+ if err != nil {
+ p.logger.Warn("environment/daytona: get working dir failed; using default runtime root", "error", err)
+ return defaultRuntimeRoot
+ }
+ if strings.TrimSpace(workingDir) == "" {
+ return defaultRuntimeRoot
+ }
+ return strings.TrimSpace(workingDir)
+}
+
+func (p *daytonaProvider) Destroy(ctx context.Context, state environment.SessionState) error {
+ if ctx == nil {
+ return errors.New("environment/daytona: destroy context is required")
+ }
+ providerState, err := decodeProviderState(state.ProviderState)
+ if err != nil {
+ return err
+ }
+ sandboxID := strings.TrimSpace(firstNonEmpty(state.InstanceID, providerState.SandboxID))
+ if sandboxID == "" {
+ return errors.New("environment/daytona: destroy missing sandbox id")
+ }
+ client, err := p.newClient(clientConfig{APIURL: providerState.APIURL})
+ if err != nil {
+ return err
+ }
+ sandbox, err := p.getSandboxForDestroy(ctx, client, sandboxID)
+ if err != nil {
+ return err
+ }
+ switch providerState.Persistence {
+ case environment.PersistenceArchive:
+ return p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ return sandbox.Archive(ctx)
+ })
+ case environment.PersistenceReuse:
+ return nil
+ default:
+ return p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ return sandbox.Delete(ctx)
+ })
+ }
+}
+
+func (p *daytonaProvider) getSandboxForDestroy(
+ ctx context.Context,
+ client sandboxClient,
+ sandboxID string,
+) (sandbox, error) {
+ var sandbox sandbox
+ err := p.withSDKTimeout(ctx, func(ctx context.Context) error {
+ var getErr error
+ sandbox, getErr = client.Get(ctx, sandboxID)
+ return getErr
+ })
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: get sandbox %q for destroy: %w", sandboxID, err)
+ }
+ return sandbox, nil
+}
+
+func (p *daytonaProvider) validateNetworkPolicy(policy environment.NetworkPolicy) error {
+ unsupported := policy.AllowOutbound || len(policy.AllowList) > 0 || len(policy.DenyList) > 0
+ if !unsupported {
+ return nil
+ }
+ message := "environment/daytona: network allow_outbound/allow_list/deny_list " +
+ "policies are not enforceable by Daytona alpha provider"
+ if policy.Required {
+ return errors.New(message)
+ }
+ p.logger.Warn(message)
+ return nil
+}
+
+func (p *daytonaProvider) withSDKTimeout(ctx context.Context, fn func(context.Context) error) error {
+ timeoutCtx, cancel := context.WithTimeout(ctx, p.sdkTimeout)
+ defer cancel()
+ if err := fn(timeoutCtx); err != nil {
+ return fmt.Errorf("environment/daytona: SDK operation failed: %w", err)
+ }
+ return nil
+}
+
+func aghLabels(req environment.PrepareRequest) map[string]string {
+ return map[string]string{
+ "agh_session_id": req.SessionID,
+ "agh_environment_id": req.EnvironmentID,
+ }
+}
+
+func parseDurationMinutes(raw string) *int {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ if minutes, err := strconv.Atoi(raw); err == nil {
+ return &minutes
+ }
+ duration, err := time.ParseDuration(raw)
+ if err != nil {
+ return nil
+ }
+ minutes := int(duration.Minutes())
+ return &minutes
+}
diff --git a/internal/environment/daytona/provider_integration_test.go b/internal/environment/daytona/provider_integration_test.go
new file mode 100644
index 000000000..5886258de
--- /dev/null
+++ b/internal/environment/daytona/provider_integration_test.go
@@ -0,0 +1,151 @@
+//go:build integration
+
+package daytona
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+func TestDaytonaProviderIntegrationFullLifecycle(t *testing.T) {
+ apiKey := os.Getenv("DAYTONA_API_KEY")
+ if apiKey == "" {
+ t.Skip("DAYTONA_API_KEY is required for Daytona provider integration tests")
+ }
+ snapshot := os.Getenv("DAYTONA_SNAPSHOT")
+ image := os.Getenv("DAYTONA_IMAGE")
+ if snapshot == "" && image == "" {
+ t.Skip("DAYTONA_SNAPSHOT or DAYTONA_IMAGE is required for Daytona provider integration tests")
+ }
+ if err := seedKnownHosts(t, daytonaSSHHost()); err != nil {
+ t.Fatalf("seedKnownHosts() error = %v", err)
+ }
+
+ root := t.TempDir()
+ writeTestFile(t, filepath.Join(root, "input.txt"), "hello from local")
+ provider := NewProvider(WithLogger(nil))
+ startupSource := environment.DaytonaStartupSourceImage
+ startupRef := image
+ if snapshot != "" {
+ startupSource = environment.DaytonaStartupSourceSnapshot
+ startupRef = snapshot
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ prepared, err := provider.Prepare(ctx, environment.PrepareRequest{
+ SessionID: "integration-daytona",
+ WorkspaceID: "workspace-integration",
+ EnvironmentID: "env-integration",
+ LocalRootDir: root,
+ Environment: environment.Resolved{
+ Profile: "daytona-integration",
+ Backend: environment.BackendDaytona,
+ SyncMode: environment.SyncModeSessionBidirectional,
+ Persistence: environment.PersistenceTransient,
+ RuntimeRootDir: "/home/daytona/agh-integration",
+ Daytona: &environment.DaytonaConfig{
+ APIURL: os.Getenv("DAYTONA_API_URL"),
+ Image: image,
+ Snapshot: snapshot,
+ StartupSource: startupSource,
+ StartupRef: startupRef,
+ },
+ },
+ AgentCommand: "cat",
+ AgentEnv: []string{"AGH_SESSION_ID=integration-daytona"},
+ Permissions: string(config.PermissionModeApproveAll),
+ })
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cleanupCancel()
+ if err := provider.Destroy(cleanupCtx, prepared.State); err != nil {
+ t.Logf("Destroy() cleanup error = %v", err)
+ }
+ })
+
+ if _, err := provider.SyncToRuntime(ctx, prepared.State, environment.SyncOptions{
+ Reason: environment.SyncReasonStart,
+ }); err != nil {
+ t.Fatalf("SyncToRuntime() error = %v", err)
+ }
+ remoteContent, err := prepared.ToolHost.ReadTextFile(ctx, "input.txt")
+ if err != nil {
+ t.Fatalf("ToolHost.ReadTextFile() error = %v", err)
+ }
+ if remoteContent != "hello from local" {
+ t.Fatalf("remote content = %q, want local content", remoteContent)
+ }
+ handle, err := prepared.Launcher.Launch(ctx, environment.LaunchSpec{
+ Command: "cat",
+ Cwd: prepared.RuntimeRootDir,
+ Env: []string{"AGH_SESSION_ID=integration-daytona"},
+ })
+ if err != nil {
+ t.Fatalf("Launch(cat) error = %v", err)
+ }
+ if _, err := handle.Stdin().Write([]byte("echo test")); err != nil {
+ t.Fatalf("handle.Stdin().Write() error = %v", err)
+ }
+ if err := handle.Stdin().Close(); err != nil {
+ t.Fatalf("handle.Stdin().Close() error = %v", err)
+ }
+ output := make([]byte, len("echo test"))
+ if _, err := io.ReadFull(handle.Stdout(), output); err != nil {
+ t.Fatalf("ReadFull(handle.Stdout()) error = %v", err)
+ }
+ if err := handle.Stop(ctx); err != nil {
+ t.Fatalf("handle.Stop() error = %v", err)
+ }
+ if string(output) != "echo test" {
+ t.Fatalf("SSH cat output = %q, want echo test", string(output))
+ }
+ if err := prepared.ToolHost.WriteTextFile(ctx, "output.txt", "hello from runtime"); err != nil {
+ t.Fatalf("ToolHost.WriteTextFile() error = %v", err)
+ }
+ if _, err := provider.SyncFromRuntime(ctx, prepared.State, environment.SyncOptions{
+ Reason: environment.SyncReasonStop,
+ }); err != nil {
+ t.Fatalf("SyncFromRuntime() error = %v", err)
+ }
+ assertFileContent(t, filepath.Join(root, "output.txt"), "hello from runtime")
+}
+
+func seedKnownHosts(t *testing.T, host string) error {
+ t.Helper()
+
+ if _, err := exec.LookPath("ssh-keyscan"); err != nil {
+ t.Skipf("ssh-keyscan is required for Daytona provider integration tests: %v", err)
+ }
+
+ home := t.TempDir()
+ sshDir := filepath.Join(home, ".ssh")
+ if err := os.MkdirAll(sshDir, 0o700); err != nil {
+ return fmt.Errorf("create ssh dir: %w", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ssh-keyscan", "-t", "ed25519", host)
+ output, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("ssh-keyscan %q: %w", host, err)
+ }
+ if err := os.WriteFile(filepath.Join(sshDir, "known_hosts"), output, 0o600); err != nil {
+ return fmt.Errorf("write known_hosts: %w", err)
+ }
+ t.Setenv("HOME", home)
+ return nil
+}
diff --git a/internal/environment/daytona/provider_test.go b/internal/environment/daytona/provider_test.go
new file mode 100644
index 000000000..c9b603387
--- /dev/null
+++ b/internal/environment/daytona/provider_test.go
@@ -0,0 +1,1185 @@
+package daytona
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+func TestDaytonaProviderPrepareCreatesSandboxWithSnapshotLabelsAndRuntime(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ sandbox := newFakeSandbox("sandbox-create")
+ client := &fakeSandboxClient{created: sandbox, findErr: errSandboxNotFound}
+ tokenSource := &fakeTokenSource{access: []sshAccess{{
+ Token: "ssh-token",
+ IssuedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ }}}
+ provider := newTestProvider(t, client, &fakeTransport{}, tokenSource, now)
+ req := newDaytonaPrepareRequest(t)
+ req.AgentEnv = []string{
+ "AGH_SESSION_ID=sess-daytona",
+ "DAYTONA_API_KEY=secret",
+ "IGNORED=value",
+ }
+ req.Environment.Env = map[string]string{
+ "NODE_ENV": "test",
+ "DAYTONA_API_KEY": "blocked",
+ "AGH_SESSION_ROLE": "profile",
+ }
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+
+ if got, want := len(client.createRequests), 1; got != want {
+ t.Fatalf("Create calls = %d, want %d", got, want)
+ }
+ create := client.createRequests[0]
+ if got, want := create.Snapshot, "snap-base"; got != want {
+ t.Fatalf("Create snapshot = %q, want %q", got, want)
+ }
+ if create.Image != "" {
+ t.Fatalf("Create image = %q, want empty when snapshot wins", create.Image)
+ }
+ if got, want := create.Labels["agh_session_id"], req.SessionID; got != want {
+ t.Fatalf("label agh_session_id = %q, want %q", got, want)
+ }
+ if got, want := create.Labels["agh_environment_id"], req.EnvironmentID; got != want {
+ t.Fatalf("label agh_environment_id = %q, want %q", got, want)
+ }
+ if _, leaked := create.EnvVars["DAYTONA_API_KEY"]; leaked {
+ t.Fatal("Create env propagated DAYTONA_API_KEY")
+ }
+ if got, want := create.EnvVars["AGH_SESSION_ID"], "sess-daytona"; got != want {
+ t.Fatalf("Create env AGH_SESSION_ID = %q, want %q", got, want)
+ }
+ if got, want := create.EnvVars["NODE_ENV"], "test"; got != want {
+ t.Fatalf("Create env NODE_ENV = %q, want %q", got, want)
+ }
+ if got, want := prepared.RuntimeRootDir, "/workspace/runtime"; got != want {
+ t.Fatalf("RuntimeRootDir = %q, want %q", got, want)
+ }
+ if got, want := prepared.State.InstanceID, sandbox.id; got != want {
+ t.Fatalf("State.InstanceID = %q, want %q", got, want)
+ }
+ if prepared.State.SSHAccessExpiresAt == nil || !prepared.State.SSHAccessExpiresAt.Equal(now.Add(time.Hour)) {
+ t.Fatalf("State.SSHAccessExpiresAt = %v, want %v", prepared.State.SSHAccessExpiresAt, now.Add(time.Hour))
+ }
+ if prepared.Launcher == nil {
+ t.Fatal("Prepared.Launcher = nil")
+ }
+ if prepared.ToolHost == nil {
+ t.Fatal("Prepared.ToolHost = nil")
+ }
+ if got := prepared.Launch.Env; !containsString(got, "AGH_SESSION_ID=sess-daytona") ||
+ !containsString(got, "NODE_ENV=test") ||
+ containsKey(got, "DAYTONA_API_KEY") ||
+ containsKey(got, "IGNORED") {
+ t.Fatalf("Launch.Env = %#v, want allowlisted remote env only", got)
+ }
+}
+
+func TestDaytonaProviderPrepareUsesImageWhenSnapshotEmpty(t *testing.T) {
+ t.Parallel()
+
+ provider, client := newProviderWithFakeClient(t)
+ req := newDaytonaPrepareRequest(t)
+ req.Environment.Daytona.Snapshot = ""
+ req.Environment.Daytona.Image = "ubuntu:24.04"
+ req.Environment.Daytona.StartupSource = environment.DaytonaStartupSourceImage
+ req.Environment.Daytona.StartupRef = "ubuntu:24.04"
+
+ if _, err := provider.Prepare(context.Background(), req); err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ if got, want := client.createRequests[0].Image, "ubuntu:24.04"; got != want {
+ t.Fatalf("Create image = %q, want %q", got, want)
+ }
+ if client.createRequests[0].Snapshot != "" {
+ t.Fatalf("Create snapshot = %q, want empty", client.createRequests[0].Snapshot)
+ }
+}
+
+func TestDaytonaProviderPrepareReattachesExistingSandbox(t *testing.T) {
+ t.Parallel()
+
+ provider, client := newProviderWithFakeClient(t)
+ req := newDaytonaPrepareRequest(t)
+ req.InstanceID = "sandbox-existing"
+ client.sandboxes["sandbox-existing"] = newFakeSandbox("sandbox-existing")
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ if got, want := client.getIDs, []string{"sandbox-existing"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("Get IDs = %#v, want %#v", got, want)
+ }
+ if len(client.createRequests) != 0 {
+ t.Fatalf("Create calls = %d, want 0", len(client.createRequests))
+ }
+ if got, want := prepared.State.InstanceID, "sandbox-existing"; got != want {
+ t.Fatalf("InstanceID = %q, want %q", got, want)
+ }
+}
+
+func TestDaytonaProviderFindEnvironmentUsesDaemonEnvironmentLabel(t *testing.T) {
+ t.Parallel()
+
+ provider, client := newProviderWithFakeClient(t)
+ client.findErr = nil
+ req := newDaytonaPrepareRequest(t)
+
+ state, err := provider.FindEnvironment(context.Background(), environment.FindEnvironmentRequest{
+ SessionID: req.SessionID,
+ WorkspaceID: req.WorkspaceID,
+ EnvironmentID: req.EnvironmentID,
+ LocalRootDir: req.LocalRootDir,
+ LocalAdditionalDirs: cloneStrings(req.LocalAdditionalDirs),
+ Environment: req.Environment,
+ })
+ if err != nil {
+ t.Fatalf("FindEnvironment() error = %v", err)
+ }
+
+ if got, want := len(client.findLabels), 1; got != want {
+ t.Fatalf("FindOne calls = %d, want %d", got, want)
+ }
+ if got, want := client.findLabels[0], map[string]string{
+ "agh_environment_id": req.EnvironmentID,
+ }; !reflect.DeepEqual(
+ got,
+ want,
+ ) {
+ t.Fatalf("FindOne labels = %#v, want %#v", got, want)
+ }
+ if got, want := state.InstanceID, client.created.id; got != want {
+ t.Fatalf("State.InstanceID = %q, want %q", got, want)
+ }
+ providerState, err := decodeProviderState(state.ProviderState)
+ if err != nil {
+ t.Fatalf("decodeProviderState() error = %v", err)
+ }
+ if got, want := providerState.SandboxID, client.created.id; got != want {
+ t.Fatalf("providerState.SandboxID = %q, want %q", got, want)
+ }
+}
+
+func TestDaytonaProviderFindEnvironmentUsesExplicitLabelsAndMapsNotFound(t *testing.T) {
+ t.Parallel()
+
+ provider, client := newProviderWithFakeClient(t)
+ req := newDaytonaPrepareRequest(t)
+ _, err := provider.FindEnvironment(context.Background(), environment.FindEnvironmentRequest{
+ SessionID: req.SessionID,
+ WorkspaceID: req.WorkspaceID,
+ EnvironmentID: req.EnvironmentID,
+ Environment: req.Environment,
+ Labels: map[string]string{"agh_environment_id": req.EnvironmentID, "custom": "true"},
+ })
+ if !errors.Is(err, environment.ErrEnvironmentNotFound) {
+ t.Fatalf("FindEnvironment() error = %v, want ErrEnvironmentNotFound", err)
+ }
+ if got, want := client.findLabels[0], map[string]string{
+ "agh_environment_id": req.EnvironmentID,
+ "custom": "true",
+ }; !reflect.DeepEqual(got, want) {
+ t.Fatalf("FindOne labels = %#v, want %#v", got, want)
+ }
+}
+
+func TestDaytonaProviderFindEnvironmentValidatesInputs(t *testing.T) {
+ t.Parallel()
+
+ provider, _ := newProviderWithFakeClient(t)
+ req := newDaytonaPrepareRequest(t)
+ var nilCtx context.Context
+ if _, err := provider.FindEnvironment(nilCtx, environment.FindEnvironmentRequest{}); err == nil {
+ t.Fatal("FindEnvironment(nil context) error = nil")
+ }
+ _, err := provider.FindEnvironment(context.Background(), environment.FindEnvironmentRequest{
+ EnvironmentID: req.EnvironmentID,
+ Environment: environment.Resolved{
+ Backend: environment.BackendLocal,
+ },
+ })
+ if err == nil {
+ t.Fatal("FindEnvironment(local backend) error = nil")
+ }
+ _, err = provider.FindEnvironment(context.Background(), environment.FindEnvironmentRequest{
+ Environment: req.Environment,
+ })
+ if err == nil {
+ t.Fatal("FindEnvironment(empty environment id) error = nil")
+ }
+}
+
+func TestDaytonaProviderSyncToRuntimeStreamsSeparateTarArchives(t *testing.T) {
+ t.Parallel()
+
+ localRoot := t.TempDir()
+ additional := t.TempDir()
+ writeTestFile(t, filepath.Join(localRoot, "root.txt"), "root")
+ writeTestFile(t, filepath.Join(additional, "extra.txt"), "extra")
+ transport := &fakeTransport{}
+ provider := newTestProviderWithTransport(transport)
+ state := newProviderSessionState(t, localRoot, []string{additional})
+
+ result, err := provider.SyncToRuntime(context.Background(), state, environment.SyncOptions{
+ Reason: environment.SyncReasonStart,
+ })
+ if err != nil {
+ t.Fatalf("SyncToRuntime() error = %v", err)
+ }
+ if got, want := result.FilesSynced, 2; got != want {
+ t.Fatalf("SyncToRuntime() FilesSynced = %d, want %d", got, want)
+ }
+ if got, want := len(transport.dials), 2; got != want {
+ t.Fatalf("transport dials = %d, want %d", got, want)
+ }
+ assertCommandContains(t, transport.dials[0].command, "tar -xpf -")
+ assertTarContains(t, transport.dials[0].session.written.Bytes(), "root.txt", "root")
+ assertTarContains(t, transport.dials[1].session.written.Bytes(), "extra.txt", "extra")
+}
+
+func TestDaytonaProviderSyncFromRuntimeAppliesTarLastWriteWins(t *testing.T) {
+ t.Parallel()
+
+ localRoot := t.TempDir()
+ additional := t.TempDir()
+ writeTestFile(t, filepath.Join(localRoot, "root.txt"), "old")
+ state := newProviderSessionState(t, localRoot, []string{additional})
+ transport := &fakeTransport{
+ readArchives: [][]byte{
+ makeTar(t, map[string]string{"root.txt": "new"}),
+ makeTar(t, map[string]string{"extra.txt": "extra"}),
+ },
+ }
+ provider := newTestProviderWithTransport(transport)
+
+ result, err := provider.SyncFromRuntime(context.Background(), state, environment.SyncOptions{
+ Reason: environment.SyncReasonStop,
+ })
+ if err != nil {
+ t.Fatalf("SyncFromRuntime() error = %v", err)
+ }
+ if got, want := result.FilesSynced, 2; got != want {
+ t.Fatalf("SyncFromRuntime() FilesSynced = %d, want %d", got, want)
+ }
+ assertFileContent(t, filepath.Join(localRoot, "root.txt"), "new")
+ assertFileContent(t, filepath.Join(additional, "extra.txt"), "extra")
+}
+
+func TestDaytonaProviderDestroyDeletesOrArchivesByPersistence(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ persistence environment.PersistenceMode
+ wantDelete int
+ wantArchive int
+ }{
+ {name: "transient deletes", persistence: environment.PersistenceTransient, wantDelete: 1},
+ {name: "archive archives", persistence: environment.PersistenceArchive, wantArchive: 1},
+ {name: "reuse leaves sandbox", persistence: environment.PersistenceReuse},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ provider, client := newProviderWithFakeClient(t)
+ sandbox := newFakeSandbox("sandbox-sync")
+ client.sandboxes[sandbox.id] = sandbox
+ state := newProviderSessionState(t, t.TempDir(), nil)
+ ps, err := decodeProviderState(state.ProviderState)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ps.Persistence = tc.persistence
+ state.ProviderState, err = encodeProviderState(ps)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := provider.Destroy(context.Background(), state); err != nil {
+ t.Fatalf("Destroy() error = %v", err)
+ }
+ if sandbox.deleteCount != tc.wantDelete || sandbox.archiveCount != tc.wantArchive {
+ t.Fatalf(
+ "delete/archive = %d/%d, want %d/%d",
+ sandbox.deleteCount,
+ sandbox.archiveCount,
+ tc.wantDelete,
+ tc.wantArchive,
+ )
+ }
+ })
+ }
+}
+
+func TestDaytonaLauncherLaunchReturnsHandleStreams(t *testing.T) {
+ t.Parallel()
+
+ transport := &fakeTransport{
+ readArchives: [][]byte{[]byte("stdout")},
+ }
+ launcher := &daytonaLauncher{
+ transport: transport,
+ sandbox: sandboxInfo{ID: "sandbox", APIURL: defaultAPIURL},
+ }
+ handle, err := launcher.Launch(context.Background(), environment.LaunchSpec{
+ Command: "cat",
+ Cwd: "/workspace",
+ Env: []string{"AGH_SESSION_ID=sess"},
+ })
+ if err != nil {
+ t.Fatalf("Launch() error = %v", err)
+ }
+ if _, err := handle.Stdin().Write([]byte("stdin")); err != nil {
+ t.Fatalf("Stdin().Write() error = %v", err)
+ }
+ output, err := io.ReadAll(handle.Stdout())
+ if err != nil {
+ t.Fatalf("ReadAll(Stdout()) error = %v", err)
+ }
+ if got, want := string(output), "stdout"; got != want {
+ t.Fatalf("Stdout = %q, want %q", got, want)
+ }
+ if got := transport.dials[0].session.written.String(); got != "stdin" {
+ t.Fatalf("captured stdin = %q, want stdin", got)
+ }
+ if handle.PID() != 0 {
+ t.Fatalf("PID() = %d, want 0 for SSH handle", handle.PID())
+ }
+ if got, want := handle.Cwd(), "/workspace"; got != want {
+ t.Fatalf("Cwd() = %q, want %q", got, want)
+ }
+ if handle.Stderr() != "" {
+ t.Fatalf("Stderr() = %q, want empty", handle.Stderr())
+ }
+ select {
+ case <-handle.Done():
+ default:
+ t.Fatal("Done() is not closed for completed fake session")
+ }
+ if err := handle.Wait(); err != nil {
+ t.Fatalf("Wait() error = %v", err)
+ }
+ if err := handle.Stop(context.Background()); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+ if err := handle.Stdin().Close(); err != nil {
+ t.Fatalf("Stdin().Close() error = %v", err)
+ }
+ if err := handle.Stdout().Close(); err != nil {
+ t.Fatalf("Stdout().Close() error = %v", err)
+ }
+}
+
+func TestDaytonaToolHostFileOpsUseSandboxFilesystem(t *testing.T) {
+ t.Parallel()
+
+ sandbox := newFakeSandbox("sandbox-tools")
+ host, err := newDaytonaToolHost(
+ sandbox,
+ &fakeTransport{},
+ sandboxInfo{ID: sandbox.id, APIURL: defaultAPIURL},
+ "/workspace",
+ config.PermissionModeApproveAll,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost() error = %v", err)
+ }
+
+ if err := host.WriteTextFile(context.Background(), "file.txt", "content"); err != nil {
+ t.Fatalf("WriteTextFile() error = %v", err)
+ }
+ content, err := host.ReadTextFile(context.Background(), "file.txt")
+ if err != nil {
+ t.Fatalf("ReadTextFile() error = %v", err)
+ }
+ if content != "content" {
+ t.Fatalf("ReadTextFile() = %q, want content", content)
+ }
+ if _, err := host.ResolvePath("../escape"); err == nil {
+ t.Fatal("ResolvePath(escape) error = nil, want error")
+ }
+}
+
+func TestDaytonaToolHostTerminalUsesSSHTransport(t *testing.T) {
+ t.Parallel()
+
+ transport := &fakeTransport{readArchives: [][]byte{[]byte("terminal output")}}
+ host, err := newDaytonaToolHost(
+ newFakeSandbox("sandbox-terminal"),
+ transport,
+ sandboxInfo{ID: "sandbox-terminal", APIURL: defaultAPIURL},
+ "/workspace",
+ config.PermissionModeApproveAll,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost() error = %v", err)
+ }
+ response, err := host.CreateTerminal(context.Background(), acpsdk.CreateTerminalRequest{Command: "echo ok"})
+ if err != nil {
+ t.Fatalf("CreateTerminal() error = %v", err)
+ }
+ if _, err := host.WaitForTerminalExit(context.Background(), response.TerminalId); err != nil {
+ t.Fatalf("WaitForTerminalExit() error = %v", err)
+ }
+ output, err := host.TerminalOutput(response.TerminalId)
+ if err != nil {
+ t.Fatalf("TerminalOutput() error = %v", err)
+ }
+ if output != "terminal output" {
+ t.Fatalf("TerminalOutput() = %q, want terminal output", output)
+ }
+ if err := host.KillTerminal(response.TerminalId); err != nil {
+ t.Fatalf("KillTerminal() error = %v", err)
+ }
+ if err := host.ReleaseTerminal(response.TerminalId); err != nil {
+ t.Fatalf("ReleaseTerminal() error = %v", err)
+ }
+ if _, err := host.TerminalOutput(response.TerminalId); err == nil {
+ t.Fatal("TerminalOutput(released) error = nil, want not found")
+ }
+}
+
+func TestDaytonaToolHostPermissionDecisionModes(t *testing.T) {
+ t.Parallel()
+
+ readKind := acpsdk.ToolKindRead
+ for _, tc := range []struct {
+ name string
+ mode config.PermissionMode
+ kind *acpsdk.ToolKind
+ want environment.PermissionDecision
+ interactive bool
+ }{
+ {
+ name: "approve all allows",
+ mode: config.PermissionModeApproveAll,
+ want: environment.PermissionDecisionAllowOnce,
+ },
+ {
+ name: "approve reads allows read",
+ mode: config.PermissionModeApproveReads,
+ kind: &readKind,
+ want: environment.PermissionDecisionAllowOnce,
+ },
+ {
+ name: "approve reads prompts write",
+ mode: config.PermissionModeApproveReads,
+ want: environment.PermissionDecisionPending,
+ interactive: true,
+ },
+ {
+ name: "deny all prompts",
+ mode: config.PermissionModeDenyAll,
+ want: environment.PermissionDecisionPending,
+ interactive: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ host, err := newDaytonaToolHost(
+ newFakeSandbox("sandbox-perm"),
+ &fakeTransport{},
+ sandboxInfo{ID: "sandbox-perm", APIURL: defaultAPIURL},
+ "/workspace",
+ tc.mode,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost() error = %v", err)
+ }
+ decision, interactive := host.PermissionDecision(acpsdk.RequestPermissionRequest{
+ ToolCall: acpsdk.RequestPermissionToolCall{
+ Kind: tc.kind,
+ Locations: []acpsdk.ToolCallLocation{{Path: "file.txt"}},
+ },
+ })
+ if decision != tc.want || interactive != tc.interactive {
+ t.Fatalf("PermissionDecision() = %q/%v, want %q/%v", decision, interactive, tc.want, tc.interactive)
+ }
+ })
+ }
+}
+
+func TestDaytonaProviderRuntimeRootFallback(t *testing.T) {
+ t.Parallel()
+
+ provider := newTestProviderWithTransport(&fakeTransport{})
+ configured := provider.runtimeRoot(context.Background(), &fakeSandbox{workingDir: "/ignored"}, "/configured")
+ if configured != "/configured" {
+ t.Fatalf("runtimeRoot(configured) = %q, want /configured", configured)
+ }
+
+ failing := &fakeSandbox{id: "sandbox-fail", files: map[string][]byte{}, workingDirErr: errors.New("boom")}
+ fallback := provider.runtimeRoot(context.Background(), failing, "")
+ if fallback != defaultRuntimeRoot {
+ t.Fatalf("runtimeRoot(failing) = %q, want %q", fallback, defaultRuntimeRoot)
+ }
+}
+
+func TestRemoteEnvAllowlist(t *testing.T) {
+ t.Parallel()
+
+ env := remoteEnvMap(
+ []string{
+ "AGH_SESSION_ID=sess",
+ "DAYTONA_API_KEY=secret",
+ "PATH=/bin",
+ },
+ map[string]string{
+ "NODE_ENV": "test",
+ "DAYTONA_API_KEY": "blocked",
+ },
+ )
+ if _, ok := env["DAYTONA_API_KEY"]; ok {
+ t.Fatal("remoteEnvMap propagated DAYTONA_API_KEY")
+ }
+ if got, want := env["AGH_SESSION_ID"], "sess"; got != want {
+ t.Fatalf("AGH_SESSION_ID = %q, want %q", got, want)
+ }
+ if got, want := env["NODE_ENV"], "test"; got != want {
+ t.Fatalf("NODE_ENV = %q, want %q", got, want)
+ }
+ if _, ok := env["PATH"]; ok {
+ t.Fatal("remoteEnvMap propagated non-allowlisted PATH")
+ }
+}
+
+func TestDaytonaNetworkPolicyWarnsOrErrorsForUnsupportedRequiredSettings(t *testing.T) {
+ t.Parallel()
+
+ provider := NewProvider(WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))).(*daytonaProvider)
+ warnPolicy := environment.NetworkPolicy{AllowOutbound: true}
+ if err := provider.validateNetworkPolicy(warnPolicy); err != nil {
+ t.Fatalf("validateNetworkPolicy(warn) error = %v", err)
+ }
+ requiredPolicy := environment.NetworkPolicy{AllowOutbound: true, Required: true}
+ if err := provider.validateNetworkPolicy(requiredPolicy); err == nil {
+ t.Fatal("validateNetworkPolicy(required) error = nil, want error")
+ }
+}
+
+func TestDaytonaProviderDefaultOptionsAndBackend(t *testing.T) {
+ t.Parallel()
+
+ provider := NewProvider(
+ WithLogger(nil),
+ withSandboxClientFactory(nil),
+ withTransport(nil),
+ withTokenManager(nil),
+ withNow(nil),
+ ).(*daytonaProvider)
+ if got, want := provider.Backend(), environment.BackendDaytona; got != want {
+ t.Fatalf("Backend() = %q, want %q", got, want)
+ }
+ if provider.logger == nil {
+ t.Fatal("logger = nil")
+ }
+ if provider.newClient == nil {
+ t.Fatal("newClient = nil")
+ }
+ if provider.tokenManager == nil {
+ t.Fatal("tokenManager = nil")
+ }
+ if provider.shellTransport == nil {
+ t.Fatal("shellTransport = nil")
+ }
+ if provider.launcherTransport == nil {
+ t.Fatal("launcherTransport = nil")
+ }
+ if provider.now == nil {
+ t.Fatal("now = nil")
+ }
+ if provider.sdkTimeout != defaultSDKTimeout {
+ t.Fatalf("sdkTimeout = %s, want %s", provider.sdkTimeout, defaultSDKTimeout)
+ }
+ if provider.createTimeout != defaultCreateTimeout {
+ t.Fatalf("createTimeout = %s, want %s", provider.createTimeout, defaultCreateTimeout)
+ }
+ if provider.sshHost != defaultSSHHost {
+ t.Fatalf("sshHost = %q, want %q", provider.sshHost, defaultSSHHost)
+ }
+}
+
+func TestDaytonaDurationParsingAndShellHelpers(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ raw string
+ want *int
+ }{
+ {name: "empty", raw: ""},
+ {name: "minutes", raw: "15", want: intPtr(15)},
+ {name: "duration", raw: "90m", want: intPtr(90)},
+ {name: "invalid", raw: "not-a-duration"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := parseDurationMinutes(tc.raw)
+ if tc.want == nil {
+ if got != nil {
+ t.Fatalf("parseDurationMinutes(%q) = %d, want nil", tc.raw, *got)
+ }
+ return
+ }
+ if got == nil || *got != *tc.want {
+ t.Fatalf("parseDurationMinutes(%q) = %v, want %d", tc.raw, got, *tc.want)
+ }
+ })
+ }
+
+ cwd := "/workspace/custom dir"
+ command := remoteTerminalCommand("/workspace/runtime", acpsdk.CreateTerminalRequest{
+ Command: "printf",
+ Args: []string{"%s", "hello world"},
+ Cwd: &cwd,
+ Env: []acpsdk.EnvVariable{
+ {Name: "AGH_SESSION_ID", Value: "sess-daytona"},
+ {Name: "DAYTONA_API_KEY", Value: "secret"},
+ {Name: "", Value: "ignored"},
+ },
+ })
+ assertCommandContains(t, command, "AGH_SESSION_ID=sess-daytona")
+ assertCommandContains(t, command, "printf")
+ if strings.Contains(command, "DAYTONA_API_KEY") || strings.Contains(command, "secret") {
+ t.Fatalf("remoteTerminalCommand leaked blocked env var: %q", command)
+ }
+
+ dirs := remoteAdditionalDirs("/workspace/runtime", []string{"/tmp/one", "/", "/tmp/%%%/two three"})
+ for _, want := range []string{
+ "/workspace/runtime/.agh-additional/01-one",
+ "/workspace/runtime/.agh-additional/02-dir",
+ "/workspace/runtime/.agh-additional/03-two-three",
+ } {
+ if !containsString(dirs, want) {
+ t.Fatalf("remoteAdditionalDirs() = %#v, missing %q", dirs, want)
+ }
+ }
+ if got, want := sanitizeRemoteBase(" ... "), defaultRemoteAdditionalBase; got != want {
+ t.Fatalf("sanitizeRemoteBase() = %q, want %q", got, want)
+ }
+}
+
+func TestDaytonaToolHostConstructorAuthorizationAndPaths(t *testing.T) {
+ t.Parallel()
+
+ sandbox := newFakeSandbox("sandbox-toolhost")
+ transport := &fakeTransport{}
+ info := sandboxInfo{ID: "sandbox-toolhost", APIURL: defaultAPIURL}
+ if _, err := newDaytonaToolHost(nil, transport, info, "/workspace", ""); err == nil {
+ t.Fatal("newDaytonaToolHost(nil sandbox) error = nil")
+ }
+ if _, err := newDaytonaToolHost(sandbox, nil, info, "/workspace", ""); err == nil {
+ t.Fatal("newDaytonaToolHost(nil transport) error = nil")
+ }
+ if _, err := newDaytonaToolHost(sandbox, transport, info, "/workspace", "invalid-mode"); err == nil {
+ t.Fatal("newDaytonaToolHost(invalid permission) error = nil")
+ }
+
+ host, err := newDaytonaToolHost(sandbox, transport, info, "/workspace", "")
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost(default permission) error = %v", err)
+ }
+ if err := host.Authorize(environment.PermissionOperationReadTextFile); err != nil {
+ t.Fatalf("Authorize(read) error = %v", err)
+ }
+ if err := host.Authorize(environment.PermissionOperationWriteTextFile); err == nil {
+ t.Fatal("Authorize(write) error = nil, want blocked by approve-reads")
+ }
+ resolved, err := host.ResolvePath("nested/file.txt")
+ if err != nil {
+ t.Fatalf("ResolvePath(relative) error = %v", err)
+ }
+ if got, want := resolved, "/workspace/nested/file.txt"; got != want {
+ t.Fatalf("ResolvePath(relative) = %q, want %q", got, want)
+ }
+ if _, err := host.ResolvePath("/outside/file.txt"); err == nil {
+ t.Fatal("ResolvePath(escape) error = nil")
+ }
+
+ allowAll, err := newDaytonaToolHost(
+ sandbox,
+ transport,
+ info,
+ "/workspace",
+ config.PermissionModeApproveAll,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost(approve-all) error = %v", err)
+ }
+ if err := allowAll.Authorize(environment.PermissionOperationCreateTerminal); err != nil {
+ t.Fatalf("Authorize(create terminal) error = %v", err)
+ }
+ decision, interactive := allowAll.PermissionDecision(acpsdk.RequestPermissionRequest{
+ ToolCall: acpsdk.RequestPermissionToolCall{
+ Locations: []acpsdk.ToolCallLocation{{Path: "/outside/file.txt"}},
+ },
+ })
+ if decision != environment.PermissionDecisionRejectOnce || interactive {
+ t.Fatalf("PermissionDecision(escape) = %q/%v, want reject_once/false", decision, interactive)
+ }
+
+ denyAll, err := newDaytonaToolHost(
+ sandbox,
+ transport,
+ info,
+ "/workspace",
+ config.PermissionModeDenyAll,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost(deny-all) error = %v", err)
+ }
+ if err := denyAll.Authorize(environment.PermissionOperationReadTextFile); err == nil {
+ t.Fatal("Authorize(read) error = nil, want blocked by deny-all")
+ }
+}
+
+func TestDaytonaToolHostTerminalOutputLimitAndFailures(t *testing.T) {
+ t.Parallel()
+
+ limit := 5
+ transport := &fakeTransport{
+ readArchives: [][]byte{[]byte("abcdef")},
+ nextStderr: "XYZ",
+ nextWaitErr: errors.New("remote failed"),
+ }
+ host, err := newDaytonaToolHost(
+ newFakeSandbox("sandbox-terminal-limit"),
+ transport,
+ sandboxInfo{ID: "sandbox-terminal-limit", APIURL: defaultAPIURL},
+ "/workspace",
+ config.PermissionModeApproveAll,
+ )
+ if err != nil {
+ t.Fatalf("newDaytonaToolHost() error = %v", err)
+ }
+ response, err := host.CreateTerminal(context.Background(), acpsdk.CreateTerminalRequest{
+ Command: "sh",
+ OutputByteLimit: &limit,
+ })
+ if err != nil {
+ t.Fatalf("CreateTerminal() error = %v", err)
+ }
+ exitCode, err := host.WaitForTerminalExit(context.Background(), response.TerminalId)
+ if err == nil {
+ t.Fatal("WaitForTerminalExit() error = nil, want wait error")
+ }
+ if exitCode != 1 {
+ t.Fatalf("WaitForTerminalExit() exitCode = %d, want 1", exitCode)
+ }
+ output, err := host.TerminalOutput(response.TerminalId)
+ if err != nil {
+ t.Fatalf("TerminalOutput() error = %v", err)
+ }
+ if got, want := output, "efXYZ"; got != want {
+ t.Fatalf("TerminalOutput() = %q, want %q", got, want)
+ }
+
+ host.terminalsMu.Lock()
+ host.terminals["slow"] = &remoteTerminal{done: make(chan struct{})}
+ host.terminalsMu.Unlock()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := host.WaitForTerminalExit(ctx, "slow"); err == nil {
+ t.Fatal("WaitForTerminalExit(canceled) error = nil")
+ }
+
+ var buf bytes.Buffer
+ appendLimited(&buf, []byte("abcdef"), 0)
+ if got, want := buf.String(), "abcdef"; got != want {
+ t.Fatalf("appendLimited(no limit) = %q, want %q", got, want)
+ }
+ appendLimited(&buf, []byte("ghijk"), 4)
+ if got, want := buf.String(), "hijk"; got != want {
+ t.Fatalf("appendLimited(limit) = %q, want %q", got, want)
+ }
+}
+
+func newProviderWithFakeClient(t *testing.T) (*daytonaProvider, *fakeSandboxClient) {
+ t.Helper()
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ client := &fakeSandboxClient{
+ created: newFakeSandbox("sandbox-created"),
+ sandboxes: make(map[string]*fakeSandbox),
+ findErr: errSandboxNotFound,
+ }
+ tokenSource := &fakeTokenSource{access: []sshAccess{{
+ Token: "ssh-token",
+ IssuedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ }}}
+ return newTestProvider(t, client, &fakeTransport{}, tokenSource, now), client
+}
+
+func newTestProvider(
+ t *testing.T,
+ client *fakeSandboxClient,
+ transport transport,
+ tokenSource sshTokenSource,
+ now time.Time,
+) *daytonaProvider {
+ t.Helper()
+ if client.sandboxes == nil {
+ client.sandboxes = make(map[string]*fakeSandbox)
+ }
+ manager := newSSHTokenManager(tokenSource, func() time.Time { return now })
+ provider := NewProvider(
+ WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
+ withSandboxClientFactory(func(clientConfig) (sandboxClient, error) { return client, nil }),
+ withTokenManager(manager),
+ withTransport(transport),
+ withNow(func() time.Time { return now }),
+ ).(*daytonaProvider)
+ provider.sdkTimeout = time.Second
+ provider.createTimeout = time.Second
+ return provider
+}
+
+func newTestProviderWithTransport(transport transport) *daytonaProvider {
+ return &daytonaProvider{
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ shellTransport: transport,
+ launcherTransport: transport,
+ sdkTimeout: time.Second,
+ now: time.Now,
+ }
+}
+
+func newDaytonaPrepareRequest(t *testing.T) environment.PrepareRequest {
+ t.Helper()
+ return environment.PrepareRequest{
+ SessionID: "sess-daytona",
+ WorkspaceID: "workspace-daytona",
+ EnvironmentID: "env-daytona",
+ LocalRootDir: t.TempDir(),
+ LocalAdditionalDirs: []string{t.TempDir()},
+ Environment: environment.Resolved{
+ Profile: "daytona-dev",
+ Backend: environment.BackendDaytona,
+ SyncMode: environment.SyncModeSessionBidirectional,
+ Persistence: environment.PersistenceTransient,
+ RuntimeRootDir: "/workspace/runtime",
+ Network: environment.NetworkPolicy{AllowPublicIngress: false},
+ Daytona: &environment.DaytonaConfig{
+ APIURL: defaultAPIURL,
+ Image: "ubuntu:24.04",
+ Snapshot: "snap-base",
+ StartupSource: environment.DaytonaStartupSourceSnapshot,
+ StartupRef: "snap-base",
+ },
+ },
+ AgentCommand: "cat",
+ AgentEnv: []string{"AGH_SESSION_ID=sess-daytona"},
+ Permissions: string(config.PermissionModeApproveAll),
+ }
+}
+
+func newProviderSessionState(
+ t *testing.T,
+ localRoot string,
+ localAdditional []string,
+) environment.SessionState {
+ t.Helper()
+ runtimeAdditional := remoteAdditionalDirs("/runtime/root", localAdditional)
+ ps := providerState{
+ Version: providerStateVersion,
+ SandboxID: "sandbox-sync",
+ APIURL: defaultAPIURL,
+ LocalRootDir: localRoot,
+ LocalAdditionalDirs: cloneStrings(localAdditional),
+ RuntimeRootDir: "/runtime/root",
+ RuntimeAdditionalDirs: runtimeAdditional,
+ Persistence: environment.PersistenceTransient,
+ }
+ raw, err := encodeProviderState(ps)
+ if err != nil {
+ t.Fatalf("encodeProviderState() error = %v", err)
+ }
+ return environment.SessionState{
+ EnvironmentID: "env-sync",
+ Backend: environment.BackendDaytona,
+ InstanceID: "sandbox-sync",
+ RuntimeRootDir: "/runtime/root",
+ RuntimeAdditionalDirs: runtimeAdditional,
+ ProviderState: raw,
+ }
+}
+
+type fakeSandboxClient struct {
+ created *fakeSandbox
+ sandboxes map[string]*fakeSandbox
+ createRequests []createSandboxRequest
+ getIDs []string
+ findLabels []map[string]string
+ findErr error
+}
+
+func (c *fakeSandboxClient) Create(_ context.Context, req createSandboxRequest) (sandbox, error) {
+ c.createRequests = append(c.createRequests, req)
+ if c.created == nil {
+ c.created = newFakeSandbox("sandbox-created")
+ }
+ c.sandboxes[c.created.id] = c.created
+ return c.created, nil
+}
+
+func (c *fakeSandboxClient) Get(_ context.Context, id string) (sandbox, error) {
+ c.getIDs = append(c.getIDs, id)
+ sandbox, ok := c.sandboxes[id]
+ if !ok {
+ return nil, errSandboxNotFound
+ }
+ return sandbox, nil
+}
+
+func (c *fakeSandboxClient) FindOne(_ context.Context, labels map[string]string) (sandbox, error) {
+ c.findLabels = append(c.findLabels, labels)
+ if c.findErr != nil {
+ return nil, c.findErr
+ }
+ return c.created, nil
+}
+
+type fakeSandbox struct {
+ id string
+ name string
+ workingDir string
+ workingDirErr error
+ files map[string][]byte
+ startCount int
+ archiveCount int
+ deleteCount int
+}
+
+func newFakeSandbox(id string) *fakeSandbox {
+ return &fakeSandbox{
+ id: id,
+ name: "name-" + id,
+ workingDir: "/workspace/runtime",
+ files: make(map[string][]byte),
+ }
+}
+
+func (s *fakeSandbox) ID() string { return s.id }
+
+func (s *fakeSandbox) Name() string { return s.name }
+
+func (s *fakeSandbox) Start(context.Context) error {
+ s.startCount++
+ return nil
+}
+
+func (s *fakeSandbox) Archive(context.Context) error {
+ s.archiveCount++
+ return nil
+}
+
+func (s *fakeSandbox) Delete(context.Context) error {
+ s.deleteCount++
+ return nil
+}
+
+func (s *fakeSandbox) WorkingDir(context.Context) (string, error) {
+ if s.workingDirErr != nil {
+ return "", s.workingDirErr
+ }
+ return s.workingDir, nil
+}
+
+func (s *fakeSandbox) ReadFile(_ context.Context, path string) ([]byte, error) {
+ content, ok := s.files[path]
+ if !ok {
+ return nil, os.ErrNotExist
+ }
+ return append([]byte(nil), content...), nil
+}
+
+func (s *fakeSandbox) WriteFile(_ context.Context, path string, content []byte) error {
+ s.files[path] = append([]byte(nil), content...)
+ return nil
+}
+
+type fakeTokenSource struct {
+ access []sshAccess
+ calls int
+}
+
+func (s *fakeTokenSource) FetchSSHAccess(
+ context.Context,
+ string,
+ string,
+ time.Duration,
+) (sshAccess, error) {
+ s.calls++
+ if len(s.access) == 0 {
+ return sshAccess{}, errors.New("missing fake token")
+ }
+ if s.calls > len(s.access) {
+ return s.access[len(s.access)-1], nil
+ }
+ return s.access[s.calls-1], nil
+}
+
+type fakeTransport struct {
+ dials []fakeDial
+ readArchives [][]byte
+ nextWaitErr error
+ nextStderr string
+}
+
+type fakeDial struct {
+ sandbox sandboxInfo
+ command string
+ session *fakeSession
+}
+
+func (t *fakeTransport) Dial(_ context.Context, sandbox sandboxInfo, command string) (transportSession, error) {
+ var read []byte
+ if len(t.readArchives) > 0 {
+ read = t.readArchives[0]
+ t.readArchives = t.readArchives[1:]
+ }
+ session := newFakeSession(read)
+ session.waitErr = t.nextWaitErr
+ session.stderr = t.nextStderr
+ t.dials = append(t.dials, fakeDial{sandbox: sandbox, command: command, session: session})
+ return session, nil
+}
+
+type fakeSession struct {
+ read *bytes.Reader
+ written bytes.Buffer
+ done chan struct{}
+ waitErr error
+ stderr string
+ closedWrite bool
+}
+
+func newFakeSession(read []byte) *fakeSession {
+ done := make(chan struct{})
+ close(done)
+ return &fakeSession{
+ read: bytes.NewReader(read),
+ done: done,
+ }
+}
+
+func (s *fakeSession) Read(p []byte) (int, error) { return s.read.Read(p) }
+
+func (s *fakeSession) Write(p []byte) (int, error) { return s.written.Write(p) }
+
+func (s *fakeSession) Close() error { return nil }
+
+func (s *fakeSession) CloseWrite() error {
+ s.closedWrite = true
+ return nil
+}
+
+func (s *fakeSession) Done() <-chan struct{} { return s.done }
+
+func (s *fakeSession) Wait() error { return s.waitErr }
+
+func (s *fakeSession) Stop(context.Context) error { return nil }
+
+func (s *fakeSession) Stderr() string { return s.stderr }
+
+func makeTar(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ writer := tar.NewWriter(&buf)
+ for name, content := range files {
+ data := []byte(content)
+ if err := writer.WriteHeader(&tar.Header{
+ Name: name,
+ Mode: 0o600,
+ Size: int64(len(data)),
+ }); err != nil {
+ t.Fatalf("WriteHeader(%q) error = %v", name, err)
+ }
+ if _, err := writer.Write(data); err != nil {
+ t.Fatalf("Write(%q) error = %v", name, err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("tar.Close() error = %v", err)
+ }
+ return buf.Bytes()
+}
+
+func assertTarContains(t *testing.T, data []byte, name string, content string) {
+ t.Helper()
+ dest := t.TempDir()
+ if _, err := extractTar(dest, bytes.NewReader(data)); err != nil {
+ t.Fatalf("extractTar(captured) error = %v", err)
+ }
+ assertFileContent(t, filepath.Join(dest, name), content)
+}
+
+func writeTestFile(t *testing.T, path string, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("WriteFile(%q) error = %v", path, err)
+ }
+}
+
+func assertFileContent(t *testing.T, path string, content string) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile(%q) error = %v", path, err)
+ }
+ if got := string(data); got != content {
+ t.Fatalf("ReadFile(%q) = %q, want %q", path, got, content)
+ }
+}
+
+func assertCommandContains(t *testing.T, command string, want string) {
+ t.Helper()
+ if !strings.Contains(command, want) {
+ t.Fatalf("command %q does not contain %q", command, want)
+ }
+}
+
+func containsString(values []string, want string) bool {
+ return slices.Contains(values, want)
+}
+
+func containsKey(values []string, key string) bool {
+ for _, value := range values {
+ if strings.HasPrefix(value, key+"=") {
+ return true
+ }
+ }
+ return false
+}
+
+func intPtr(value int) *int {
+ return &value
+}
diff --git a/internal/environment/daytona/sdk.go b/internal/environment/daytona/sdk.go
new file mode 100644
index 000000000..aaa0824c4
--- /dev/null
+++ b/internal/environment/daytona/sdk.go
@@ -0,0 +1,217 @@
+package daytona
+
+import (
+ "context"
+ stderrors "errors"
+ "fmt"
+ "time"
+
+ daytonasdk "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
+ daytonaerrors "github.com/daytonaio/daytona/libs/sdk-go/pkg/errors"
+ daytonaoptions "github.com/daytonaio/daytona/libs/sdk-go/pkg/options"
+ daytonatypes "github.com/daytonaio/daytona/libs/sdk-go/pkg/types"
+)
+
+var errSandboxNotFound = stderrors.New("environment/daytona: sandbox not found")
+
+type sandboxClientFactory func(config clientConfig) (sandboxClient, error)
+
+type clientConfig struct {
+ APIURL string
+ Target string
+}
+
+type createSandboxRequest struct {
+ Name string
+ Labels map[string]string
+ EnvVars map[string]string
+ Public bool
+ Snapshot string
+ Image string
+ AutoStopMinutes *int
+ AutoArchiveMinutes *int
+ Timeout time.Duration
+}
+
+type sandboxClient interface {
+ Create(ctx context.Context, req createSandboxRequest) (sandbox, error)
+ Get(ctx context.Context, id string) (sandbox, error)
+ FindOne(ctx context.Context, labels map[string]string) (sandbox, error)
+}
+
+type sandbox interface {
+ ID() string
+ Name() string
+ Start(ctx context.Context) error
+ Archive(ctx context.Context) error
+ Delete(ctx context.Context) error
+ WorkingDir(ctx context.Context) (string, error)
+ ReadFile(ctx context.Context, path string) ([]byte, error)
+ WriteFile(ctx context.Context, path string, content []byte) error
+}
+
+func newSDKClient(config clientConfig) (sandboxClient, error) {
+ client, err := daytonasdk.NewClientWithConfig(&daytonatypes.DaytonaConfig{
+ APIUrl: normalizeAPIURL(config.APIURL),
+ Target: config.Target,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: create Daytona SDK client: %w", err)
+ }
+ return &sdkClient{client: client}, nil
+}
+
+type sdkClient struct {
+ client *daytonasdk.Client
+}
+
+func (c *sdkClient) Create(ctx context.Context, req createSandboxRequest) (sandbox, error) {
+ base := daytonatypes.SandboxBaseParams{
+ Name: req.Name,
+ EnvVars: req.EnvVars,
+ Labels: req.Labels,
+ Public: req.Public,
+ Language: daytonatypes.CodeLanguagePython,
+ }
+ if req.AutoStopMinutes != nil {
+ base.AutoStopInterval = req.AutoStopMinutes
+ }
+ if req.AutoArchiveMinutes != nil {
+ base.AutoArchiveInterval = req.AutoArchiveMinutes
+ }
+
+ var params any
+ switch {
+ case req.Snapshot != "":
+ params = daytonatypes.SnapshotParams{
+ SandboxBaseParams: base,
+ Snapshot: req.Snapshot,
+ }
+ case req.Image != "":
+ params = daytonatypes.ImageParams{
+ SandboxBaseParams: base,
+ Image: req.Image,
+ }
+ default:
+ params = daytonatypes.SnapshotParams{SandboxBaseParams: base}
+ }
+
+ opts := []func(*daytonaoptions.CreateSandbox){}
+ if req.Timeout > 0 {
+ opts = append(opts, daytonaoptions.WithTimeout(req.Timeout))
+ }
+
+ sandbox, err := c.client.Create(ctx, params, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: create sandbox: %w", err)
+ }
+ return sdkSandbox{sandbox: sandbox}, nil
+}
+
+func (c *sdkClient) Get(ctx context.Context, id string) (sandbox, error) {
+ sandbox, err := c.client.Get(ctx, id)
+ if err != nil {
+ return nil, mapSDKNotFound("get sandbox", err)
+ }
+ return sdkSandbox{sandbox: sandbox}, nil
+}
+
+func (c *sdkClient) FindOne(ctx context.Context, labels map[string]string) (sandbox, error) {
+ limit := 1
+ result, err := c.client.List(ctx, labels, nil, &limit)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: list sandboxes by labels: %w", err)
+ }
+ if result == nil || len(result.Items) == 0 {
+ return nil, errSandboxNotFound
+ }
+ return sdkSandbox{sandbox: result.Items[0]}, nil
+}
+
+type sdkSandbox struct {
+ sandbox *daytonasdk.Sandbox
+}
+
+func (s sdkSandbox) ID() string {
+ if s.sandbox == nil {
+ return ""
+ }
+ return s.sandbox.ID
+}
+
+func (s sdkSandbox) Name() string {
+ if s.sandbox == nil {
+ return ""
+ }
+ return s.sandbox.Name
+}
+
+func (s sdkSandbox) Start(ctx context.Context) error {
+ if s.sandbox == nil {
+ return errSandboxNotFound
+ }
+ if err := s.sandbox.Start(ctx); err != nil {
+ return fmt.Errorf("environment/daytona: start sandbox %q: %w", s.sandbox.ID, err)
+ }
+ return nil
+}
+
+func (s sdkSandbox) Archive(ctx context.Context) error {
+ if s.sandbox == nil {
+ return errSandboxNotFound
+ }
+ if err := s.sandbox.Archive(ctx); err != nil {
+ return fmt.Errorf("environment/daytona: archive sandbox %q: %w", s.sandbox.ID, err)
+ }
+ return nil
+}
+
+func (s sdkSandbox) Delete(ctx context.Context) error {
+ if s.sandbox == nil {
+ return errSandboxNotFound
+ }
+ if err := s.sandbox.Delete(ctx); err != nil {
+ return fmt.Errorf("environment/daytona: delete sandbox %q: %w", s.sandbox.ID, err)
+ }
+ return nil
+}
+
+func (s sdkSandbox) WorkingDir(ctx context.Context) (string, error) {
+ if s.sandbox == nil {
+ return "", errSandboxNotFound
+ }
+ dir, err := s.sandbox.GetWorkingDir(ctx)
+ if err != nil {
+ return "", fmt.Errorf("environment/daytona: get sandbox %q working dir: %w", s.sandbox.ID, err)
+ }
+ return dir, nil
+}
+
+func (s sdkSandbox) ReadFile(ctx context.Context, path string) ([]byte, error) {
+ if s.sandbox == nil {
+ return nil, errSandboxNotFound
+ }
+ content, err := s.sandbox.FileSystem.DownloadFile(ctx, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: read file %q: %w", path, err)
+ }
+ return content, nil
+}
+
+func (s sdkSandbox) WriteFile(ctx context.Context, path string, content []byte) error {
+ if s.sandbox == nil {
+ return errSandboxNotFound
+ }
+ if err := s.sandbox.FileSystem.UploadFile(ctx, content, path); err != nil {
+ return fmt.Errorf("environment/daytona: write file %q: %w", path, err)
+ }
+ return nil
+}
+
+func mapSDKNotFound(operation string, err error) error {
+ var notFound *daytonaerrors.DaytonaNotFoundError
+ if stderrors.As(err, ¬Found) {
+ return fmt.Errorf("%w: %s", errSandboxNotFound, operation)
+ }
+ return fmt.Errorf("environment/daytona: %s: %w", operation, err)
+}
diff --git a/internal/environment/daytona/sdk_test.go b/internal/environment/daytona/sdk_test.go
new file mode 100644
index 000000000..88b46a034
--- /dev/null
+++ b/internal/environment/daytona/sdk_test.go
@@ -0,0 +1,187 @@
+package daytona
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ daytonaerrors "github.com/daytonaio/daytona/libs/sdk-go/pkg/errors"
+)
+
+func TestSDKClientAdapterUsesDaytonaAPI(t *testing.T) {
+ t.Setenv("DAYTONA_API_KEY", "test-key")
+
+ var serverURL string
+ var seenCreate bool
+ var seenUpload bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/sandbox":
+ seenCreate = true
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode create body: %v", err)
+ }
+ if body["snapshot"] != "snap-sdk" {
+ t.Errorf("create snapshot = %#v, want snap-sdk", body["snapshot"])
+ }
+ writeJSON(t, w, sandboxResponse(serverURL, "sandbox-sdk"))
+ case r.Method == http.MethodGet && r.URL.Path == "/sandbox/sandbox-sdk":
+ writeJSON(t, w, sandboxResponse(serverURL, "sandbox-sdk"))
+ case r.Method == http.MethodGet && r.URL.Path == "/sandbox/paginated":
+ writeJSON(t, w, map[string]any{
+ "items": []map[string]any{sandboxResponse(serverURL, "sandbox-sdk")},
+ "total": 1,
+ "page": 1,
+ "totalPages": 1,
+ })
+ case r.Method == http.MethodPost && r.URL.Path == "/sandbox/sandbox-sdk/start":
+ writeJSON(t, w, sandboxResponse(serverURL, "sandbox-sdk"))
+ case r.Method == http.MethodPost && r.URL.Path == "/sandbox/sandbox-sdk/archive":
+ writeJSON(t, w, sandboxResponse(serverURL, "sandbox-sdk"))
+ case r.Method == http.MethodDelete && r.URL.Path == "/sandbox/sandbox-sdk":
+ writeJSON(t, w, sandboxResponse(serverURL, "sandbox-sdk"))
+ case r.Method == http.MethodGet && r.URL.Path == "/toolbox/sandbox-sdk/work-dir":
+ writeJSON(t, w, map[string]any{"dir": "/workdir"})
+ case r.Method == http.MethodGet && r.URL.Path == "/toolbox/sandbox-sdk/files/download":
+ if got, want := r.URL.Query().Get("path"), "/workdir/file.txt"; got != want {
+ t.Errorf("download path = %q, want %q", got, want)
+ }
+ _, _ = w.Write([]byte("downloaded"))
+ case r.Method == http.MethodPost && r.URL.Path == "/toolbox/sandbox-sdk/files/upload":
+ seenUpload = true
+ writeJSON(t, w, map[string]any{"file": map[string]any{"ok": true}})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ serverURL = server.URL
+
+ client, err := newSDKClient(clientConfig{APIURL: server.URL})
+ if err != nil {
+ t.Fatalf("newSDKClient() error = %v", err)
+ }
+ ctx := context.Background()
+ created, err := client.Create(ctx, createSandboxRequest{
+ Snapshot: "snap-sdk",
+ Labels: map[string]string{"agh_environment_id": "env-sdk"},
+ EnvVars: map[string]string{"AGH_SESSION_ID": "sess-sdk"},
+ Timeout: time.Second,
+ })
+ if err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+ if !seenCreate {
+ t.Fatal("server did not observe create request")
+ }
+ if got, want := created.ID(), "sandbox-sdk"; got != want {
+ t.Fatalf("created.ID() = %q, want %q", got, want)
+ }
+ got, err := client.Get(ctx, "sandbox-sdk")
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if got.Name() == "" {
+ t.Fatal("Get().Name() = empty")
+ }
+ found, err := client.FindOne(ctx, map[string]string{"agh_environment_id": "env-sdk"})
+ if err != nil {
+ t.Fatalf("FindOne() error = %v", err)
+ }
+ if found.ID() != "sandbox-sdk" {
+ t.Fatalf("FindOne().ID() = %q, want sandbox-sdk", found.ID())
+ }
+ if err := created.Start(ctx); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ if dir, err := created.WorkingDir(ctx); err != nil || dir != "/workdir" {
+ t.Fatalf("WorkingDir() = %q, %v; want /workdir nil", dir, err)
+ }
+ content, err := created.ReadFile(ctx, "/workdir/file.txt")
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ if string(content) != "downloaded" {
+ t.Fatalf("ReadFile() = %q, want downloaded", string(content))
+ }
+ if err := created.WriteFile(ctx, "/workdir/file.txt", []byte("upload")); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+ if !seenUpload {
+ t.Fatal("server did not observe upload request")
+ }
+ if err := created.Archive(ctx); err != nil {
+ t.Fatalf("Archive() error = %v", err)
+ }
+ if err := created.Delete(ctx); err != nil {
+ t.Fatalf("Delete() error = %v", err)
+ }
+}
+
+func TestSDKClientAdapterMapsEmptyFindToNotFound(t *testing.T) {
+ t.Setenv("DAYTONA_API_KEY", "test-key")
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/sandbox/paginated" {
+ http.NotFound(w, r)
+ return
+ }
+ writeJSON(t, w, map[string]any{"items": []map[string]any{}, "total": 0, "page": 1, "totalPages": 0})
+ }))
+ defer server.Close()
+ client, err := newSDKClient(clientConfig{APIURL: server.URL})
+ if err != nil {
+ t.Fatalf("newSDKClient() error = %v", err)
+ }
+ if _, err := client.FindOne(context.Background(), map[string]string{"missing": "true"}); err == nil {
+ t.Fatal("FindOne() error = nil, want not found")
+ }
+}
+
+func TestMapSDKNotFound(t *testing.T) {
+ t.Parallel()
+
+ notFound := mapSDKNotFound("get sandbox", daytonaerrors.NewDaytonaNotFoundError("missing", nil))
+ if !errors.Is(notFound, errSandboxNotFound) {
+ t.Fatalf("mapSDKNotFound(not found) = %v, want errSandboxNotFound", notFound)
+ }
+ other := mapSDKNotFound("get sandbox", errors.New("boom"))
+ if errors.Is(other, errSandboxNotFound) {
+ t.Fatalf("mapSDKNotFound(other) = %v, did not want errSandboxNotFound", other)
+ }
+}
+
+func sandboxResponse(serverURL string, id string) map[string]any {
+ return map[string]any{
+ "id": id,
+ "organizationId": "org",
+ "name": "sandbox-name",
+ "user": "daytona",
+ "env": map[string]string{},
+ "labels": map[string]string{"agh_environment_id": "env-sdk"},
+ "public": false,
+ "networkBlockAll": false,
+ "target": "default",
+ "cpu": 1,
+ "gpu": 0,
+ "memory": 1,
+ "disk": 1,
+ "state": "started",
+ "toolboxProxyUrl": strings.TrimRight(serverURL, "/") + "/toolbox",
+ "autoStopInterval": 0,
+ "autoDeleteInterval": -1,
+ }
+}
+
+func writeJSON(t *testing.T, w http.ResponseWriter, value map[string]any) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(value); err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+}
diff --git a/internal/environment/daytona/shell.go b/internal/environment/daytona/shell.go
new file mode 100644
index 000000000..d31d15996
--- /dev/null
+++ b/internal/environment/daytona/shell.go
@@ -0,0 +1,117 @@
+package daytona
+
+import (
+ "fmt"
+ "path"
+ "strings"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ "github.com/kballard/go-shellquote"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+const defaultRemoteAdditionalBase = "dir"
+
+func remoteLaunchCommand(spec environment.LaunchSpec) string {
+ cwd := spec.Cwd
+ if cwd == "" {
+ cwd = defaultRuntimeRoot
+ }
+ env := make([]string, 0, len(spec.Env))
+ for _, entry := range spec.Env {
+ if key, _, ok := strings.Cut(entry, "="); ok && key != "" {
+ env = append(env, shellquote.Join(entry))
+ }
+ }
+ parts := []string{"cd", shellquote.Join(cwd), "&&"}
+ if len(env) > 0 {
+ parts = append(parts, "env")
+ parts = append(parts, env...)
+ }
+ parts = append(parts, "sh", "-lc", shellquote.Join(spec.Command))
+ return strings.Join(parts, " ")
+}
+
+func remoteTerminalCommand(root string, req acpsdk.CreateTerminalRequest) string {
+ cwd := root
+ if req.Cwd != nil && strings.TrimSpace(*req.Cwd) != "" {
+ cwd = *req.Cwd
+ }
+ command := strings.TrimSpace(req.Command)
+ if len(req.Args) > 0 {
+ args := make([]string, 0, len(req.Args)+1)
+ args = append(args, command)
+ args = append(args, req.Args...)
+ command = shellquote.Join(args...)
+ }
+ env := make([]string, 0, len(req.Env))
+ for _, entry := range req.Env {
+ if entry.Name == "" || isBlockedRemoteEnv(entry.Name) {
+ continue
+ }
+ env = append(env, shellquote.Join(fmt.Sprintf("%s=%s", entry.Name, entry.Value)))
+ }
+ parts := []string{"cd", shellquote.Join(cwd), "&&"}
+ if len(env) > 0 {
+ parts = append(parts, "env")
+ parts = append(parts, env...)
+ }
+ parts = append(parts, "sh", "-lc", shellquote.Join(command))
+ return strings.Join(parts, " ")
+}
+
+func remoteExtractCommand(dest string, payloadBytes int64) string {
+ return fmt.Sprintf(
+ "mkdir -p %s && head -c %d | tar -xpf - -C %s",
+ shellquote.Join(dest),
+ payloadBytes,
+ shellquote.Join(dest),
+ )
+}
+
+func remoteArchiveCommand(src string) string {
+ return "tar -cpf - -C " + shellquote.Join(src) + " ."
+}
+
+func remoteAdditionalDirs(runtimeRoot string, localAdditionalDirs []string) []string {
+ if len(localAdditionalDirs) == 0 {
+ return nil
+ }
+ baseRoot := path.Join(runtimeRoot, ".agh-additional")
+ dirs := make([]string, 0, len(localAdditionalDirs))
+ for i, localDir := range localAdditionalDirs {
+ base := path.Base(strings.TrimRight(localDir, "/"))
+ if base == "." || base == "/" || base == "" {
+ base = defaultRemoteAdditionalBase
+ }
+ dirs = append(dirs, path.Join(baseRoot, fmt.Sprintf("%02d-%s", i+1, sanitizeRemoteBase(base))))
+ }
+ return dirs
+}
+
+func sanitizeRemoteBase(base string) string {
+ base = strings.TrimSpace(base)
+ if base == "" {
+ return defaultRemoteAdditionalBase
+ }
+ var builder strings.Builder
+ for _, r := range base {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r)
+ case r >= 'A' && r <= 'Z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '-' || r == '_' || r == '.':
+ builder.WriteRune(r)
+ default:
+ builder.WriteByte('-')
+ }
+ }
+ cleaned := strings.Trim(builder.String(), ".-")
+ if cleaned == "" {
+ return defaultRemoteAdditionalBase
+ }
+ return cleaned
+}
diff --git a/internal/environment/daytona/sidecar_transport.go b/internal/environment/daytona/sidecar_transport.go
new file mode 100644
index 000000000..0ef86dc27
--- /dev/null
+++ b/internal/environment/daytona/sidecar_transport.go
@@ -0,0 +1,775 @@
+package daytona
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/kballard/go-shellquote"
+)
+
+const (
+ launcherSidecarPort = 40241
+ launcherSidecarVersion = "agh-daytona-launcher-sidecar-v1"
+ launcherSidecarPath = "/tmp/agh-daytona-launcher-sidecar-v1"
+ launcherSidecarLogPath = "/tmp/agh-daytona-launcher-sidecar-v1.log"
+ sidecarHealthPath = "healthz"
+ sidecarLaunchPath = "v1/launch"
+ sidecarSessionStreamBasePath = "v1/sessions"
+ sidecarHealthTimeout = 30 * time.Second
+ sidecarHealthPollInterval = 200 * time.Millisecond
+ sidecarRequestTimeout = 30 * time.Second
+ sidecarCloseTimeout = 5 * time.Second
+ sidecarBuildTimeout = 2 * time.Minute
+ sidecarFrameClientStdin byte = 0x01
+ sidecarFrameClientCloseStdin = 0x02
+ sidecarFrameClientStop = 0x03
+ sidecarFrameServerStdout byte = 0x01
+ sidecarFrameServerStderr = 0x02
+ sidecarFrameServerExit = 0x03
+ sidecarFrameServerError = 0x04
+)
+
+type sidecarTransport struct {
+ logger *slog.Logger
+ newClient sandboxClientFactory
+ bootstrap transport
+ clientDialer sshClientDialer
+ httpClient *http.Client
+ healthTimeout time.Duration
+ healthPollInterval time.Duration
+ closeTimeout time.Duration
+ binaryMu sync.Mutex
+ binaries map[string][]byte
+}
+
+type sidecarEndpoint struct {
+ base *url.URL
+ httpClient *http.Client
+ wsDialer *websocket.Dialer
+ closeFn func() error
+}
+
+type sidecarHealthResponse struct {
+ OK bool `json:"ok"`
+ Version string `json:"version"`
+}
+
+type sidecarLaunchRequest struct {
+ Command string `json:"command"`
+}
+
+type sidecarLaunchResponse struct {
+ ID string `json:"id"`
+}
+
+type sidecarExitPayload struct {
+ ExitCode int `json:"exitCode"`
+ Stderr string `json:"stderr"`
+}
+
+type deadlineConn struct {
+ net.Conn
+}
+
+func (c deadlineConn) SetDeadline(time.Time) error {
+ return nil
+}
+
+func (c deadlineConn) SetReadDeadline(time.Time) error {
+ return nil
+}
+
+func (c deadlineConn) SetWriteDeadline(time.Time) error {
+ return nil
+}
+
+func newSidecarTransport(
+ logger *slog.Logger,
+ newClient sandboxClientFactory,
+ bootstrap transport,
+) *sidecarTransport {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ var clientDialer sshClientDialer
+ if dialer, ok := bootstrap.(sshClientDialer); ok {
+ clientDialer = dialer
+ }
+ return &sidecarTransport{
+ logger: logger,
+ newClient: newClient,
+ bootstrap: bootstrap,
+ clientDialer: clientDialer,
+ httpClient: &http.Client{
+ Timeout: sidecarRequestTimeout,
+ },
+ healthTimeout: sidecarHealthTimeout,
+ healthPollInterval: sidecarHealthPollInterval,
+ closeTimeout: sidecarCloseTimeout,
+ binaries: make(map[string][]byte),
+ }
+}
+
+func (t *sidecarTransport) Dial(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ command string,
+) (transportSession, error) {
+ if t == nil {
+ return nil, errors.New("environment/daytona: launcher sidecar transport is required")
+ }
+ endpoint, err := t.ensureSidecar(ctx, sandbox)
+ if err != nil {
+ return nil, err
+ }
+ sessionID, err := t.launch(ctx, endpoint, command)
+ if err != nil {
+ return nil, err
+ }
+ return t.connect(ctx, endpoint, sessionID)
+}
+
+func (t *sidecarTransport) ensureSidecar(
+ ctx context.Context,
+ info sandboxInfo,
+) (sidecarEndpoint, error) {
+ sandbox, err := t.loadSandbox(ctx, info)
+ if err != nil {
+ return sidecarEndpoint{}, err
+ }
+ binary, err := t.sidecarBinary(ctx, info)
+ if err != nil {
+ return sidecarEndpoint{}, err
+ }
+ if err := sandbox.WriteFile(ctx, launcherSidecarPath, binary); err != nil {
+ return sidecarEndpoint{}, fmt.Errorf("environment/daytona: upload launcher sidecar: %w", err)
+ }
+ endpoint, err := t.openTunnel(ctx, info)
+ if err != nil {
+ return sidecarEndpoint{}, err
+ }
+ healthy, err := t.health(ctx, endpoint)
+ if err == nil && healthy {
+ return endpoint, nil
+ }
+ if err := t.startSidecar(ctx, info); err != nil {
+ return sidecarEndpoint{}, err
+ }
+ if err := t.waitForHealth(ctx, endpoint); err != nil {
+ return sidecarEndpoint{}, err
+ }
+ return endpoint, nil
+}
+
+func (t *sidecarTransport) openTunnel(ctx context.Context, sandbox sandboxInfo) (sidecarEndpoint, error) {
+ if t.clientDialer == nil {
+ return sidecarEndpoint{}, errors.New("environment/daytona: launcher sidecar SSH tunnel is not configured")
+ }
+ client, err := t.clientDialer.DialClient(ctx, sandbox)
+ if err != nil {
+ return sidecarEndpoint{}, fmt.Errorf("environment/daytona: open launcher sidecar SSH tunnel: %w", err)
+ }
+ baseURL, err := url.Parse("http://sidecar")
+ if err != nil {
+ _ = client.Close()
+ return sidecarEndpoint{}, fmt.Errorf("environment/daytona: parse launcher sidecar tunnel base URL: %w", err)
+ }
+ targetAddr := fmt.Sprintf("127.0.0.1:%d", launcherSidecarPort)
+ httpTransport := &http.Transport{
+ DialContext: func(context.Context, string, string) (net.Conn, error) {
+ conn, err := client.Dial("tcp", targetAddr)
+ if err != nil {
+ return nil, err
+ }
+ return deadlineConn{Conn: conn}, nil
+ },
+ }
+ httpClient := &http.Client{
+ Transport: httpTransport,
+ Timeout: sidecarRequestTimeout,
+ }
+ wsDialer := &websocket.Dialer{
+ NetDialContext: func(context.Context, string, string) (net.Conn, error) {
+ conn, err := client.Dial("tcp", targetAddr)
+ if err != nil {
+ return nil, err
+ }
+ return deadlineConn{Conn: conn}, nil
+ },
+ }
+ return sidecarEndpoint{
+ base: baseURL,
+ httpClient: httpClient,
+ wsDialer: wsDialer,
+ closeFn: func() error {
+ httpTransport.CloseIdleConnections()
+ return client.Close()
+ },
+ }, nil
+}
+
+func (t *sidecarTransport) sidecarBinary(ctx context.Context, sandbox sandboxInfo) ([]byte, error) {
+ arch, err := t.remoteArch(ctx, sandbox)
+ if err != nil {
+ return nil, err
+ }
+
+ t.binaryMu.Lock()
+ cached := append([]byte(nil), t.binaries[arch]...)
+ t.binaryMu.Unlock()
+ if len(cached) != 0 {
+ return cached, nil
+ }
+
+ built, err := t.buildSidecarBinary(ctx, arch)
+ if err != nil {
+ return nil, err
+ }
+ t.binaryMu.Lock()
+ t.binaries[arch] = append([]byte(nil), built...)
+ t.binaryMu.Unlock()
+ return built, nil
+}
+
+func (t *sidecarTransport) remoteArch(ctx context.Context, sandbox sandboxInfo) (string, error) {
+ if t.bootstrap == nil {
+ return "", errors.New("environment/daytona: launcher sidecar bootstrap transport is required")
+ }
+ session, err := t.bootstrap.Dial(ctx, sandbox, "uname -m")
+ if err != nil {
+ return "", fmt.Errorf("environment/daytona: detect sandbox architecture: %w", err)
+ }
+ defer func() {
+ if closeErr := session.Close(); closeErr != nil {
+ t.logger.Warn("environment/daytona: close arch probe session failed", "error", closeErr)
+ }
+ }()
+ output, readErr := io.ReadAll(session)
+ waitErr := session.Wait()
+ if err := errors.Join(readErr, waitErr); err != nil {
+ return "", fmt.Errorf("environment/daytona: detect sandbox architecture: %w stderr=%q", err, session.Stderr())
+ }
+ switch strings.TrimSpace(string(output)) {
+ case "x86_64", "amd64":
+ return "amd64", nil
+ case "aarch64", "arm64":
+ return "arm64", nil
+ default:
+ return "", fmt.Errorf(
+ "environment/daytona: unsupported sandbox architecture %q",
+ strings.TrimSpace(string(output)),
+ )
+ }
+}
+
+func (t *sidecarTransport) buildSidecarBinary(ctx context.Context, arch string) ([]byte, error) {
+ goBinary, err := exec.LookPath("go")
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: go toolchain is required to build launcher sidecar: %w", err)
+ }
+ buildCtx, cancel := context.WithTimeout(ctx, sidecarBuildTimeout)
+ defer cancel()
+
+ tmpFile, err := os.CreateTemp("", "agh-daytona-sidecar-*")
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: create launcher sidecar temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ if err := tmpFile.Close(); err != nil {
+ return nil, fmt.Errorf("environment/daytona: close launcher sidecar temp file: %w", err)
+ }
+ defer os.Remove(tmpPath)
+
+ cmd := exec.CommandContext(buildCtx, goBinary, "build", "-o", tmpPath, t.sidecarSourceDir())
+ cacheRoot := filepath.Join(os.TempDir(), "agh-daytona-sidecar-go")
+ modCache := filepath.Join(cacheRoot, "mod")
+ buildCache := filepath.Join(cacheRoot, "build")
+ if err := os.MkdirAll(modCache, 0o755); err != nil {
+ return nil, fmt.Errorf("environment/daytona: create launcher sidecar module cache: %w", err)
+ }
+ if err := os.MkdirAll(buildCache, 0o755); err != nil {
+ return nil, fmt.Errorf("environment/daytona: create launcher sidecar build cache: %w", err)
+ }
+ cmd.Env = append(
+ os.Environ(),
+ "CGO_ENABLED=0",
+ "GOOS=linux",
+ "GOARCH="+arch,
+ "GOMODCACHE="+modCache,
+ "GOCACHE="+buildCache,
+ )
+ cmd.Dir = t.repoRootDir()
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf(
+ "environment/daytona: build launcher sidecar for %s: %w: %s",
+ arch,
+ err,
+ strings.TrimSpace(string(output)),
+ )
+ }
+ binary, err := os.ReadFile(tmpPath)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: read launcher sidecar binary: %w", err)
+ }
+ return binary, nil
+}
+
+func (t *sidecarTransport) loadSandbox(ctx context.Context, info sandboxInfo) (sandbox, error) {
+ if t.newClient == nil {
+ return nil, errors.New("environment/daytona: launcher sidecar sandbox client is required")
+ }
+ client, err := t.newClient(clientConfig{APIURL: info.APIURL})
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: launcher sidecar create client: %w", err)
+ }
+ sandbox, err := client.Get(ctx, info.ID)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: launcher sidecar load sandbox %q: %w", info.ID, err)
+ }
+ return sandbox, nil
+}
+
+func (t *sidecarTransport) repoRootDir() string {
+ _, filename, _, ok := runtime.Caller(0)
+ if !ok {
+ return "."
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", ".."))
+}
+
+func (t *sidecarTransport) sidecarSourceDir() string {
+ return "./internal/environment/daytona/cmd/agh-daytona-sidecar"
+}
+
+func (t *sidecarTransport) health(ctx context.Context, endpoint sidecarEndpoint) (bool, error) {
+ requestCtx, cancel := context.WithTimeout(ctx, sidecarRequestTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(
+ requestCtx,
+ http.MethodGet,
+ endpoint.url(sidecarHealthPath),
+ http.NoBody,
+ )
+ if err != nil {
+ return false, fmt.Errorf("environment/daytona: build sidecar health request: %w", err)
+ }
+ client := endpoint.httpClient
+ if client == nil {
+ client = t.httpClient
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("environment/daytona: launcher sidecar health status %d", resp.StatusCode)
+ }
+ var payload sidecarHealthResponse
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return false, fmt.Errorf("environment/daytona: decode launcher sidecar health: %w", err)
+ }
+ return payload.OK && payload.Version == launcherSidecarVersion, nil
+}
+
+func (t *sidecarTransport) waitForHealth(ctx context.Context, endpoint sidecarEndpoint) error {
+ waitCtx, cancel := context.WithTimeout(ctx, t.healthTimeout)
+ defer cancel()
+
+ ticker := time.NewTicker(t.healthPollInterval)
+ defer ticker.Stop()
+
+ for {
+ healthy, err := t.health(waitCtx, endpoint)
+ if err == nil && healthy {
+ return nil
+ }
+ select {
+ case <-waitCtx.Done():
+ if err != nil {
+ return fmt.Errorf("environment/daytona: wait for launcher sidecar health: %w", err)
+ }
+ return fmt.Errorf(
+ "environment/daytona: wait for launcher sidecar health: %w",
+ waitCtx.Err(),
+ )
+ case <-ticker.C:
+ }
+ }
+}
+
+func (t *sidecarTransport) startSidecar(ctx context.Context, sandbox sandboxInfo) error {
+ if t.bootstrap == nil {
+ return errors.New("environment/daytona: launcher sidecar bootstrap transport is required")
+ }
+ session, err := t.bootstrap.Dial(ctx, sandbox, launcherSidecarStartCommand())
+ if err != nil {
+ return fmt.Errorf("environment/daytona: start launcher sidecar: %w", err)
+ }
+ defer func() {
+ if closeErr := session.Close(); closeErr != nil {
+ t.logger.Warn("environment/daytona: close launcher bootstrap session failed", "error", closeErr)
+ }
+ }()
+ if err := session.Wait(); err != nil {
+ stderr := strings.TrimSpace(session.Stderr())
+ if stderr != "" {
+ return fmt.Errorf("environment/daytona: start launcher sidecar: %w stderr=%q", err, stderr)
+ }
+ return fmt.Errorf("environment/daytona: start launcher sidecar: %w", err)
+ }
+ return nil
+}
+
+func (t *sidecarTransport) launch(
+ ctx context.Context,
+ endpoint sidecarEndpoint,
+ command string,
+) (string, error) {
+ body, err := json.Marshal(sidecarLaunchRequest{Command: command})
+ if err != nil {
+ return "", fmt.Errorf("environment/daytona: marshal sidecar launch request: %w", err)
+ }
+ requestCtx, cancel := context.WithTimeout(ctx, sidecarRequestTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(
+ requestCtx,
+ http.MethodPost,
+ endpoint.url(sidecarLaunchPath),
+ bytes.NewReader(body),
+ )
+ if err != nil {
+ return "", fmt.Errorf("environment/daytona: build sidecar launch request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := endpoint.httpClient
+ if client == nil {
+ client = t.httpClient
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("environment/daytona: launch command via sidecar: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ payload := readResponseSnippet(resp.Body)
+ return "", fmt.Errorf(
+ "environment/daytona: sidecar launch status %d: %s",
+ resp.StatusCode,
+ payload,
+ )
+ }
+ var launched sidecarLaunchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&launched); err != nil {
+ return "", fmt.Errorf("environment/daytona: decode sidecar launch response: %w", err)
+ }
+ if strings.TrimSpace(launched.ID) == "" {
+ return "", errors.New("environment/daytona: sidecar launch response missing session id")
+ }
+ return strings.TrimSpace(launched.ID), nil
+}
+
+func (t *sidecarTransport) connect(
+ ctx context.Context,
+ endpoint sidecarEndpoint,
+ sessionID string,
+) (transportSession, error) {
+ dialer := endpoint.wsDialer
+ if dialer == nil {
+ dialer = websocket.DefaultDialer
+ }
+ conn, resp, err := dialer.DialContext(
+ ctx,
+ endpoint.wsURL(sidecarSessionStreamBasePath, sessionID, "stream"),
+ nil,
+ )
+ if err != nil {
+ if resp != nil && resp.Body != nil {
+ _ = resp.Body.Close()
+ }
+ return nil, fmt.Errorf("environment/daytona: connect launcher sidecar stream: %w", err)
+ }
+ httpClient := endpoint.httpClient
+ if httpClient == nil {
+ httpClient = t.httpClient
+ }
+ return newSidecarSession(conn, endpoint, sessionID, httpClient, t.closeTimeout), nil
+}
+
+func (e sidecarEndpoint) url(parts ...string) string {
+ clone := *e.base
+ joined := append([]string{strings.TrimSuffix(clone.Path, "/")}, parts...)
+ clone.Path = path.Join(joined...)
+ clone.RawPath = ""
+ return clone.String()
+}
+
+func (e sidecarEndpoint) wsURL(parts ...string) string {
+ u := e.url(parts...)
+ parsed, err := url.Parse(u)
+ if err != nil {
+ return u
+ }
+ switch parsed.Scheme {
+ case "https":
+ parsed.Scheme = "wss"
+ case "http":
+ parsed.Scheme = "ws"
+ }
+ return parsed.String()
+}
+
+func launcherSidecarStartCommand() string {
+ script := strings.Join([]string{
+ "chmod 755 " + shellquote.Join(launcherSidecarPath),
+ "&&",
+ "nohup",
+ shellquote.Join(launcherSidecarPath),
+ "--port",
+ fmt.Sprintf("%d", launcherSidecarPort),
+ ">" + shellquote.Join(launcherSidecarLogPath),
+ "2>&1",
+ "= http.StatusMultipleChoices {
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ if readErr != nil {
+ return sshAccess{}, fmt.Errorf(
+ "environment/daytona: fetch SSH access token status %d and read error body: %w",
+ resp.StatusCode,
+ readErr,
+ )
+ }
+ return sshAccess{}, fmt.Errorf(
+ "environment/daytona: fetch SSH access token status %d: %s",
+ resp.StatusCode,
+ string(body),
+ )
+ }
+
+ var raw struct {
+ Token string `json:"token"`
+ ExpiresAt string `json:"expiresAt"`
+ ExpiresAtSnake string `json:"expires_at"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
+ return sshAccess{}, fmt.Errorf("environment/daytona: decode SSH access response: %w", err)
+ }
+ if raw.Token == "" {
+ return sshAccess{}, errors.New("environment/daytona: SSH access response missing token")
+ }
+ now := s.now().UTC()
+ expiresAt := now.Add(expiresIn)
+ if raw.ExpiresAt != "" {
+ if parsed, err := time.Parse(time.RFC3339, raw.ExpiresAt); err == nil {
+ expiresAt = parsed.UTC()
+ }
+ }
+ if raw.ExpiresAtSnake != "" {
+ if parsed, err := time.Parse(time.RFC3339, raw.ExpiresAtSnake); err == nil {
+ expiresAt = parsed.UTC()
+ }
+ }
+ return sshAccess{Token: raw.Token, IssuedAt: now, ExpiresAt: expiresAt}, nil
+}
+
+type sshTokenManager struct {
+ source sshTokenSource
+ now func() time.Time
+ expiresIn time.Duration
+ mu sync.Mutex
+ tokens map[string]sshAccess
+}
+
+func newSSHTokenManager(source sshTokenSource, now func() time.Time) *sshTokenManager {
+ if now == nil {
+ now = time.Now
+ }
+ return &sshTokenManager{
+ source: source,
+ now: now,
+ expiresIn: defaultSSHAccessExpiresIn,
+ tokens: make(map[string]sshAccess),
+ }
+}
+
+func (m *sshTokenManager) Ensure(
+ ctx context.Context,
+ apiURL string,
+ sandboxID string,
+ force bool,
+) (sshAccess, error) {
+ if m == nil || m.source == nil {
+ return sshAccess{}, errors.New("environment/daytona: SSH token manager is not configured")
+ }
+ key := tokenCacheKey(apiURL, sandboxID)
+ m.mu.Lock()
+ cached, ok := m.tokens[key]
+ if ok && !force && !m.shouldRefresh(cached) {
+ m.mu.Unlock()
+ return cached, nil
+ }
+ m.mu.Unlock()
+
+ access, err := m.source.FetchSSHAccess(ctx, apiURL, sandboxID, m.expiresIn)
+ if err != nil {
+ return sshAccess{}, err
+ }
+ m.mu.Lock()
+ m.tokens[key] = access
+ m.mu.Unlock()
+ return access, nil
+}
+
+func (m *sshTokenManager) shouldRefresh(access sshAccess) bool {
+ if access.Token == "" || access.ExpiresAt.IsZero() {
+ return true
+ }
+ now := m.now().UTC()
+ if !now.Before(access.ExpiresAt) {
+ return true
+ }
+ issuedAt := access.IssuedAt
+ if issuedAt.IsZero() {
+ issuedAt = access.ExpiresAt.Add(-m.expiresIn)
+ }
+ refreshAt := issuedAt.Add(access.ExpiresAt.Sub(issuedAt) / 2)
+ return !now.Before(refreshAt)
+}
+
+func tokenCacheKey(apiURL string, sandboxID string) string {
+ return normalizeAPIURL(apiURL) + "\x00" + sandboxID
+}
+
+type sshDialer func(
+ ctx context.Context,
+ network string,
+ address string,
+ config *ssh.ClientConfig,
+) (*ssh.Client, error)
+
+func defaultSSHDialer(
+ ctx context.Context,
+ network string,
+ address string,
+ config *ssh.ClientConfig,
+) (*ssh.Client, error) {
+ dialer := net.Dialer{Timeout: defaultSSHDialTimeout}
+ conn, err := dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ clientConn, chans, reqs, err := ssh.NewClientConn(conn, address, config)
+ if err != nil {
+ if closeErr := conn.Close(); closeErr != nil {
+ err = errors.Join(err, closeErr)
+ }
+ return nil, err
+ }
+ return ssh.NewClient(clientConn, chans, reqs), nil
+}
+
+type sshTransport struct {
+ tokens *sshTokenManager
+ host string
+ port string
+ dial sshDialer
+ hostKeyCallback ssh.HostKeyCallback
+ keepAlive time.Duration
+ now func() time.Time
+}
+
+type sshClientDialer interface {
+ DialClient(ctx context.Context, sandbox sandboxInfo) (*ssh.Client, error)
+}
+
+func newSSHTransport(tokens *sshTokenManager, opts ...func(*sshTransport)) *sshTransport {
+ transport := &sshTransport{
+ tokens: tokens,
+ host: defaultSSHHost,
+ port: defaultSSHPort,
+ dial: defaultSSHDialer,
+ hostKeyCallback: defaultHostKeyCallback(),
+ keepAlive: defaultSSHKeepAlive,
+ now: time.Now,
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(transport)
+ }
+ }
+ if transport.host == "" {
+ transport.host = defaultSSHHost
+ }
+ if transport.port == "" {
+ transport.port = defaultSSHPort
+ }
+ if transport.dial == nil {
+ transport.dial = defaultSSHDialer
+ }
+ if transport.hostKeyCallback == nil {
+ transport.hostKeyCallback = defaultHostKeyCallback()
+ }
+ if transport.keepAlive <= 0 {
+ transport.keepAlive = defaultSSHKeepAlive
+ }
+ return transport
+}
+
+func (t *sshTransport) Dial(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ command string,
+) (transportSession, error) {
+ session, err := t.dialWithFreshness(ctx, sandbox, command, false)
+ if err == nil {
+ return session, nil
+ }
+ retry, retryErr := t.dialWithFreshness(ctx, sandbox, command, true)
+ if retryErr != nil {
+ return nil, errors.Join(err, retryErr)
+ }
+ return retry, nil
+}
+
+func (t *sshTransport) DialClient(ctx context.Context, sandbox sandboxInfo) (*ssh.Client, error) {
+ client, err := t.dialClientWithFreshness(ctx, sandbox, false)
+ if err == nil {
+ return client, nil
+ }
+ retry, retryErr := t.dialClientWithFreshness(ctx, sandbox, true)
+ if retryErr != nil {
+ return nil, errors.Join(err, retryErr)
+ }
+ return retry, nil
+}
+
+func (t *sshTransport) dialWithFreshness(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ command string,
+ forceToken bool,
+) (transportSession, error) {
+ client, err := t.dialClientWithFreshness(ctx, sandbox, forceToken)
+ if err != nil {
+ return nil, err
+ }
+ session, err := client.NewSession()
+ if err != nil {
+ if closeErr := client.Close(); closeErr != nil {
+ err = errors.Join(err, closeErr)
+ }
+ return nil, fmt.Errorf("environment/daytona: create SSH session for sandbox %q: %w", sandbox.ID, err)
+ }
+ stdin, err := session.StdinPipe()
+ if err != nil {
+ closeSSH(client, session)
+ return nil, fmt.Errorf("environment/daytona: open SSH stdin for sandbox %q: %w", sandbox.ID, err)
+ }
+ stdout, err := session.StdoutPipe()
+ if err != nil {
+ closeSSH(client, session)
+ return nil, fmt.Errorf("environment/daytona: open SSH stdout for sandbox %q: %w", sandbox.ID, err)
+ }
+ var stderr bytes.Buffer
+ session.Stderr = &stderr
+ if err := session.Start(command); err != nil {
+ closeSSH(client, session)
+ return nil, fmt.Errorf("environment/daytona: start SSH command in sandbox %q: %w", sandbox.ID, err)
+ }
+ return newSSHSession(client, session, stdin, stdout, &stderr, t.keepAlive), nil
+}
+
+func (t *sshTransport) dialClientWithFreshness(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ forceToken bool,
+) (*ssh.Client, error) {
+ access, err := t.tokens.Ensure(ctx, sandbox.APIURL, sandbox.ID, forceToken)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: ensure SSH access for sandbox %q: %w", sandbox.ID, err)
+ }
+ config := &ssh.ClientConfig{
+ User: access.Token,
+ Auth: []ssh.AuthMethod{ssh.Password("")},
+ HostKeyCallback: t.hostKeyCallback,
+ Timeout: defaultSSHDialTimeout,
+ }
+ address := net.JoinHostPort(normalizeSSHHost(firstNonEmpty(sandbox.SSHHost, t.host)), t.port)
+ client, err := t.dial(ctx, "tcp", address, config)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: dial SSH sandbox %q: %w", sandbox.ID, err)
+ }
+ return client, nil
+}
+
+func closeSSH(client *ssh.Client, session *ssh.Session) {
+ if session != nil {
+ _ = session.Close()
+ }
+ if client != nil {
+ _ = client.Close()
+ }
+}
+
+type sshSession struct {
+ client *ssh.Client
+ session *ssh.Session
+ stdin io.WriteCloser
+ stdout io.Reader
+ stderr *bytes.Buffer
+ closeOnce sync.Once
+ done chan struct{}
+ waitErr error
+ cancel context.CancelFunc
+}
+
+func newSSHSession(
+ client *ssh.Client,
+ session *ssh.Session,
+ stdin io.WriteCloser,
+ stdout io.Reader,
+ stderr *bytes.Buffer,
+ keepAlive time.Duration,
+) *sshSession {
+ ctx, cancel := context.WithCancel(context.Background())
+ remote := &sshSession{
+ client: client,
+ session: session,
+ stdin: stdin,
+ stdout: stdout,
+ stderr: stderr,
+ done: make(chan struct{}),
+ cancel: cancel,
+ }
+ go remote.keepAlive(ctx, keepAlive)
+ go func() {
+ remote.waitErr = normalizeSSHWaitErr(session.Wait(), stderr)
+ cancel()
+ close(remote.done)
+ }()
+ return remote
+}
+
+func (s *sshSession) Read(p []byte) (int, error) {
+ return s.stdout.Read(p)
+}
+
+func (s *sshSession) Write(p []byte) (int, error) {
+ return s.stdin.Write(p)
+}
+
+func (s *sshSession) CloseWrite() error {
+ if s.stdin == nil {
+ return nil
+ }
+ return s.stdin.Close()
+}
+
+func (s *sshSession) Close() error {
+ var err error
+ s.closeOnce.Do(func() {
+ if s.cancel != nil {
+ s.cancel()
+ }
+ err = errors.Join(s.CloseWrite(), s.session.Close(), s.client.Close())
+ })
+ return err
+}
+
+func (s *sshSession) Done() <-chan struct{} {
+ return s.done
+}
+
+func (s *sshSession) Wait() error {
+ <-s.done
+ return s.waitErr
+}
+
+func (s *sshSession) Stop(ctx context.Context) error {
+ if err := s.Close(); err != nil {
+ return err
+ }
+ select {
+ case <-s.done:
+ return s.waitErr
+ case <-ctx.Done():
+ return fmt.Errorf("environment/daytona: stop SSH session: %w", ctx.Err())
+ }
+}
+
+func (s *sshSession) Stderr() string {
+ if s.stderr == nil {
+ return ""
+ }
+ return s.stderr.String()
+}
+
+func (s *sshSession) keepAlive(ctx context.Context, interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ _, _, err := s.client.SendRequest("keepalive@openssh.com", true, nil)
+ if err != nil {
+ return
+ }
+ }
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func defaultHostKeyCallback() ssh.HostKeyCallback {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return func(hostname string, _ net.Addr, _ ssh.PublicKey) error {
+ return fmt.Errorf("environment/daytona: resolve home for SSH known_hosts %q: %w", hostname, err)
+ }
+ }
+ callback, err := knownhosts.New(filepath.Join(home, ".ssh", "known_hosts"))
+ if err != nil {
+ return func(hostname string, _ net.Addr, _ ssh.PublicKey) error {
+ return fmt.Errorf("environment/daytona: load SSH known_hosts for %q: %w", hostname, err)
+ }
+ }
+ return callback
+}
+
+func normalizeSSHWaitErr(err error, stderr *bytes.Buffer) error {
+ if err == nil {
+ return nil
+ }
+ var missing *ssh.ExitMissingError
+ if errors.As(err, &missing) {
+ if stderr == nil || strings.TrimSpace(stderr.String()) == "" {
+ return nil
+ }
+ }
+ return err
+}
diff --git a/internal/environment/daytona/ssh_test.go b/internal/environment/daytona/ssh_test.go
new file mode 100644
index 000000000..9d56d18f2
--- /dev/null
+++ b/internal/environment/daytona/ssh_test.go
@@ -0,0 +1,258 @@
+package daytona
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func TestSSHTransportDialConnectsAndStreams(t *testing.T) {
+ t.Parallel()
+
+ server := newTestSSHServer(t, "valid-token")
+ tokenSource := &fakeTokenSource{access: []sshAccess{{
+ Token: "valid-token",
+ IssuedAt: time.Now().UTC(),
+ ExpiresAt: time.Now().Add(time.Hour).UTC(),
+ }}}
+ transport := newSSHTransport(
+ newSSHTokenManager(tokenSource, time.Now),
+ func(t *sshTransport) {
+ t.host = server.host
+ t.port = server.port
+ t.hostKeyCallback = ssh.InsecureIgnoreHostKey()
+ t.keepAlive = time.Hour
+ },
+ )
+
+ session, err := transport.Dial(context.Background(), sandboxInfo{ID: "sandbox", APIURL: defaultAPIURL}, "cat")
+ if err != nil {
+ t.Fatalf("Dial() error = %v", err)
+ }
+ if _, err := session.Write([]byte("hello")); err != nil {
+ t.Fatalf("Write() error = %v", err)
+ }
+ if err := session.CloseWrite(); err != nil {
+ t.Fatalf("CloseWrite() error = %v", err)
+ }
+ output, err := io.ReadAll(session)
+ if err != nil {
+ t.Fatalf("ReadAll() error = %v", err)
+ }
+ if got, want := string(output), "hello"; got != want {
+ t.Fatalf("output = %q, want %q", got, want)
+ }
+ if err := session.Wait(); err != nil {
+ t.Fatalf("Wait() error = %v", err)
+ }
+ select {
+ case <-session.Done():
+ default:
+ t.Fatal("Done() was not closed after Wait()")
+ }
+ if stderr := session.Stderr(); stderr != "" {
+ t.Fatalf("Stderr() = %q, want empty", stderr)
+ }
+ if err := session.Stop(context.Background()); err != nil {
+ t.Logf("Stop() after wait returned expected closed-session error: %v", err)
+ }
+}
+
+func TestSSHTransportDialRetriesWithFreshTokenAfterAuthFailure(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().UTC()
+ server := newTestSSHServer(t, "fresh-token")
+ tokenSource := &fakeTokenSource{access: []sshAccess{
+ {Token: "expired-token", IssuedAt: now, ExpiresAt: now.Add(time.Hour)},
+ {Token: "fresh-token", IssuedAt: now, ExpiresAt: now.Add(time.Hour)},
+ }}
+ transport := newSSHTransport(
+ newSSHTokenManager(tokenSource, func() time.Time { return now }),
+ func(t *sshTransport) {
+ t.host = server.host
+ t.port = server.port
+ t.hostKeyCallback = ssh.InsecureIgnoreHostKey()
+ t.keepAlive = time.Hour
+ },
+ )
+
+ session, err := transport.Dial(context.Background(), sandboxInfo{ID: "sandbox", APIURL: defaultAPIURL}, "cat")
+ if err != nil {
+ t.Fatalf("Dial() error = %v", err)
+ }
+ if err := session.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+ if got, want := tokenSource.calls, 2; got != want {
+ t.Fatalf("FetchSSHAccess calls = %d, want %d", got, want)
+ }
+}
+
+func TestSSHTransportDialFailsWithInvalidToken(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().UTC()
+ server := newTestSSHServer(t, "valid-token")
+ tokenSource := &fakeTokenSource{access: []sshAccess{{
+ Token: "invalid-token",
+ IssuedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ }}}
+ transport := newSSHTransport(
+ newSSHTokenManager(tokenSource, func() time.Time { return now }),
+ func(t *sshTransport) {
+ t.host = server.host
+ t.port = server.port
+ t.hostKeyCallback = ssh.InsecureIgnoreHostKey()
+ t.keepAlive = time.Hour
+ },
+ )
+
+ if _, err := transport.Dial(
+ context.Background(),
+ sandboxInfo{ID: "sandbox", APIURL: defaultAPIURL},
+ "cat",
+ ); err == nil {
+ t.Fatal("Dial() error = nil, want invalid token error")
+ }
+}
+
+func TestSSHTokenManagerRefreshesAtHalfExpiry(t *testing.T) {
+ t.Parallel()
+
+ issued := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ now := issued
+ source := &fakeTokenSource{access: []sshAccess{
+ {Token: "first", IssuedAt: issued, ExpiresAt: issued.Add(time.Hour)},
+ {Token: "second", IssuedAt: issued.Add(31 * time.Minute), ExpiresAt: issued.Add(91 * time.Minute)},
+ }}
+ manager := newSSHTokenManager(source, func() time.Time { return now })
+
+ first, err := manager.Ensure(context.Background(), defaultAPIURL, "sandbox", false)
+ if err != nil {
+ t.Fatalf("Ensure(first) error = %v", err)
+ }
+ now = issued.Add(29 * time.Minute)
+ cached, err := manager.Ensure(context.Background(), defaultAPIURL, "sandbox", false)
+ if err != nil {
+ t.Fatalf("Ensure(cached) error = %v", err)
+ }
+ now = issued.Add(31 * time.Minute)
+ refreshed, err := manager.Ensure(context.Background(), defaultAPIURL, "sandbox", false)
+ if err != nil {
+ t.Fatalf("Ensure(refreshed) error = %v", err)
+ }
+ if first.Token != "first" || cached.Token != "first" || refreshed.Token != "second" {
+ t.Fatalf("tokens = %q/%q/%q, want first/first/second", first.Token, cached.Token, refreshed.Token)
+ }
+ if got, want := source.calls, 2; got != want {
+ t.Fatalf("FetchSSHAccess calls = %d, want %d", got, want)
+ }
+}
+
+func TestDefaultHostKeyCallbackRequiresKnownHosts(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ callback := defaultHostKeyCallback()
+ if err := callback("example.com", nil, nil); err == nil {
+ t.Fatal("defaultHostKeyCallback() error = nil, want missing known_hosts error")
+ }
+}
+
+type testSSHServer struct {
+ host string
+ port string
+}
+
+func newTestSSHServer(t *testing.T, validUser string) testSSHServer {
+ t.Helper()
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("GenerateKey() error = %v", err)
+ }
+ signer, err := ssh.NewSignerFromKey(privateKey)
+ if err != nil {
+ t.Fatalf("NewSignerFromKey() error = %v", err)
+ }
+ config := &ssh.ServerConfig{
+ PasswordCallback: func(meta ssh.ConnMetadata, _ []byte) (*ssh.Permissions, error) {
+ if meta.User() == validUser {
+ return nil, nil
+ }
+ return nil, errors.New("invalid user")
+ },
+ }
+ config.AddHostKey(signer)
+
+ listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("Listen() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := listener.Close(); err != nil {
+ t.Logf("listener.Close() error = %v", err)
+ }
+ })
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go handleTestSSHConn(conn, config)
+ }
+ }()
+ host, port, err := net.SplitHostPort(listener.Addr().String())
+ if err != nil {
+ t.Fatalf("SplitHostPort() error = %v", err)
+ }
+ return testSSHServer{host: host, port: port}
+}
+
+func handleTestSSHConn(conn net.Conn, config *ssh.ServerConfig) {
+ server, chans, reqs, err := ssh.NewServerConn(conn, config)
+ if err != nil {
+ return
+ }
+ defer server.Close()
+ go ssh.DiscardRequests(reqs)
+ for newChannel := range chans {
+ if newChannel.ChannelType() != "session" {
+ _ = newChannel.Reject(ssh.UnknownChannelType, "unsupported")
+ continue
+ }
+ channel, requests, err := newChannel.Accept()
+ if err != nil {
+ continue
+ }
+ go handleTestSSHSession(channel, requests)
+ }
+}
+
+func handleTestSSHSession(channel ssh.Channel, requests <-chan *ssh.Request) {
+ defer channel.Close()
+ for req := range requests {
+ switch req.Type {
+ case "exec":
+ _ = req.Reply(true, nil)
+ if bytes.Contains(req.Payload, []byte("cat")) {
+ _, _ = io.Copy(channel, channel)
+ }
+ _, _ = channel.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
+ return
+ default:
+ _ = req.Reply(false, nil)
+ }
+ }
+}
diff --git a/internal/environment/daytona/ssh_token_test.go b/internal/environment/daytona/ssh_token_test.go
new file mode 100644
index 000000000..fe5ac9d8d
--- /dev/null
+++ b/internal/environment/daytona/ssh_token_test.go
@@ -0,0 +1,65 @@
+package daytona
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestRESTSSHTokenSourceFetchesTokenAndExpiry(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
+ expiresAt := now.Add(30 * time.Minute)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got, want := r.URL.Path, "/sandbox/sandbox-token/ssh-access"; got != want {
+ t.Fatalf("path = %q, want %q", got, want)
+ }
+ if got, want := r.URL.Query().Get("expiresInMinutes"), "60"; got != want {
+ t.Fatalf("expiresInMinutes = %q, want %q", got, want)
+ }
+ if got, want := r.Header.Get("Authorization"), "Bearer api-key"; got != want {
+ t.Fatalf("Authorization = %q, want %q", got, want)
+ }
+ writeJSON(t, w, map[string]any{
+ "token": "ssh-token",
+ "expiresAt": expiresAt.Format(time.RFC3339),
+ })
+ }))
+ defer server.Close()
+ source := &restSSHTokenSource{
+ httpClient: server.Client(),
+ apiKey: func() string { return "api-key" },
+ now: func() time.Time { return now },
+ }
+ access, err := source.FetchSSHAccess(context.Background(), server.URL, "sandbox-token", time.Hour)
+ if err != nil {
+ t.Fatalf("FetchSSHAccess() error = %v", err)
+ }
+ if access.Token != "ssh-token" || !access.IssuedAt.Equal(now) || !access.ExpiresAt.Equal(expiresAt) {
+ t.Fatalf("access = %#v, want parsed token/expiry", access)
+ }
+}
+
+func TestRESTSSHTokenSourceRejectsMissingKeyAndBadStatus(t *testing.T) {
+ t.Parallel()
+
+ source := &restSSHTokenSource{apiKey: func() string { return "" }, now: time.Now}
+ if _, err := source.FetchSSHAccess(context.Background(), defaultAPIURL, "sandbox", time.Hour); err == nil {
+ t.Fatal("FetchSSHAccess(missing key) error = nil, want error")
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "nope", http.StatusUnauthorized)
+ }))
+ defer server.Close()
+ source = &restSSHTokenSource{
+ httpClient: server.Client(),
+ apiKey: func() string { return "api-key" },
+ now: time.Now,
+ }
+ if _, err := source.FetchSSHAccess(context.Background(), server.URL, "sandbox", time.Hour); err == nil {
+ t.Fatal("FetchSSHAccess(bad status) error = nil, want error")
+ }
+}
diff --git a/internal/environment/daytona/ssh_validation_test.go b/internal/environment/daytona/ssh_validation_test.go
new file mode 100644
index 000000000..6646bedae
--- /dev/null
+++ b/internal/environment/daytona/ssh_validation_test.go
@@ -0,0 +1,566 @@
+//go:build integration
+
+package daytona
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+const (
+ daytonaAPIKeyEnv = "DAYTONA_API_KEY"
+ daytonaAPIURLEnv = "DAYTONA_API_URL"
+ daytonaOrganizationEnv = "DAYTONA_ORGANIZATION_ID"
+ daytonaSSHHostEnv = "DAYTONA_SSH_HOST"
+ daytonaValidateSSHEnv = "DAYTONA_VALIDATE_SSH_GATEWAY"
+ defaultDaytonaAPIURL = "https://app.daytona.io/api"
+ defaultDaytonaSSHHost = "ssh.app.daytona.io"
+ sshAccessExpiryMinutes = "60"
+ httpClientTimeout = 2 * time.Minute
+ testTimeout = 5 * time.Minute
+ cleanupTimeout = time.Minute
+ sshCommandTimeout = 45 * time.Second
+ sshReadyTimeout = 30 * time.Second
+ sshReadyRetryInterval = 500 * time.Millisecond
+ sshCloseTimeout = 3 * time.Second
+ latencyThreshold = 100 * time.Millisecond
+ maxResponseBodyBytes = 1 << 20
+)
+
+var sshReadyMarker = []byte("__agh_daytona_ssh_ready__")
+
+func TestDaytonaSSHNonPTYValidation(t *testing.T) {
+ apiKey := strings.TrimSpace(os.Getenv(daytonaAPIKeyEnv))
+ if apiKey == "" {
+ t.Skipf("%s is required for Daytona SSH validation", daytonaAPIKeyEnv)
+ }
+ if strings.TrimSpace(os.Getenv(daytonaValidateSSHEnv)) == "" {
+ t.Skipf(
+ "%s is diagnostic-only now that the launcher uses the sidecar transport",
+ daytonaValidateSSHEnv,
+ )
+ }
+ if _, err := exec.LookPath("ssh"); err != nil {
+ t.Skipf("OpenSSH client is required for Daytona SSH validation: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+ t.Cleanup(cancel)
+
+ client := newDaytonaValidationClient(apiKey)
+ sandboxID, err := client.createSandbox(ctx)
+ if err != nil {
+ t.Fatalf("create Daytona sandbox: %v", err)
+ }
+ t.Logf("created Daytona sandbox %q for non-PTY SSH validation", sandboxID)
+ t.Cleanup(func() {
+ cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), cleanupTimeout)
+ defer cleanupCancel()
+ if cleanupErr := client.deleteSandbox(cleanupCtx, sandboxID); cleanupErr != nil {
+ t.Errorf("delete Daytona sandbox %q: %v", sandboxID, cleanupErr)
+ }
+ })
+
+ sshAccess, err := client.createSSHAccess(ctx, sandboxID)
+ if err != nil {
+ t.Fatalf("create SSH access for Daytona sandbox %q: %v", sandboxID, err)
+ }
+ target := sshAccess.Token + "@" + daytonaSSHHost()
+
+ session, attempts, err := openSSHCatSession(ctx, target)
+ if err != nil {
+ t.Fatalf("open Daytona SSH validation session: %v", err)
+ }
+ if attempts > 1 {
+ t.Logf("Daytona SSH gateway became ready after %d attempts", attempts)
+ }
+ t.Cleanup(func() {
+ trailing, timedOut, cleanupErr := session.Close()
+ if timedOut {
+ t.Logf("Daytona SSH validation session did not exit after stdin close within %s; terminated local client", sshCloseTimeout)
+ }
+ if cleanupErr != nil {
+ t.Errorf("close Daytona SSH validation session: %v", cleanupErr)
+ }
+ if len(trailing) != 0 {
+ t.Errorf("SSH stdout included trailing bytes after session close: %s", previewBytes(trailing))
+ }
+ if containsTerminalArtifact(trailing) {
+ t.Errorf("SSH stdout trailing bytes contain terminal artifact bytes: %s", previewBytes(trailing))
+ }
+ })
+
+ runPayloadChecks(t, session)
+ runLatencyCheck(t, session)
+}
+
+type daytonaValidationClient struct {
+ apiKey string
+ apiURL string
+ organizationID string
+ httpClient *http.Client
+}
+
+type sshAccessResponse struct {
+ Token string `json:"token"`
+}
+
+type sshRoundTripResult struct {
+ output []byte
+ latency time.Duration
+}
+
+type sshCatSession struct {
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+ stdout io.ReadCloser
+ stderr *lockedBuffer
+}
+
+type lockedBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *lockedBuffer) Write(data []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(data)
+}
+
+func (b *lockedBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+func newDaytonaValidationClient(apiKey string) daytonaValidationClient {
+ apiURL := strings.TrimRight(strings.TrimSpace(os.Getenv(daytonaAPIURLEnv)), "/")
+ if apiURL == "" {
+ apiURL = defaultDaytonaAPIURL
+ }
+ return daytonaValidationClient{
+ apiKey: apiKey,
+ apiURL: apiURL,
+ organizationID: strings.TrimSpace(os.Getenv(daytonaOrganizationEnv)),
+ httpClient: &http.Client{Timeout: httpClientTimeout},
+ }
+}
+
+func (c daytonaValidationClient) createSandbox(ctx context.Context) (string, error) {
+ var raw json.RawMessage
+ if err := c.doJSON(ctx, http.MethodPost, []string{"sandbox"}, nil, map[string]string{}, &raw); err != nil {
+ return "", err
+ }
+ sandboxID, err := extractSandboxID(raw)
+ if err != nil {
+ return "", fmt.Errorf("extract sandbox id from create response: %w", err)
+ }
+ return sandboxID, nil
+}
+
+func (c daytonaValidationClient) createSSHAccess(ctx context.Context, sandboxID string) (sshAccessResponse, error) {
+ query := url.Values{"expiresInMinutes": []string{sshAccessExpiryMinutes}}
+ var response sshAccessResponse
+ if err := c.doJSON(
+ ctx,
+ http.MethodPost,
+ []string{"sandbox", sandboxID, "ssh-access"},
+ query,
+ nil,
+ &response,
+ ); err != nil {
+ return sshAccessResponse{}, err
+ }
+ if strings.TrimSpace(response.Token) == "" {
+ return sshAccessResponse{}, errors.New("Daytona ssh-access response did not include token")
+ }
+ return response, nil
+}
+
+func (c daytonaValidationClient) deleteSandbox(ctx context.Context, sandboxID string) error {
+ return c.doJSON(ctx, http.MethodDelete, []string{"sandbox", sandboxID}, nil, nil, nil)
+}
+
+func (c daytonaValidationClient) doJSON(
+ ctx context.Context,
+ method string,
+ pathParts []string,
+ query url.Values,
+ body any,
+ out any,
+) (err error) {
+ endpoint, err := c.endpoint(pathParts, query)
+ if err != nil {
+ return err
+ }
+ var bodyReader io.Reader
+ if body != nil {
+ encoded, marshalErr := json.Marshal(body)
+ if marshalErr != nil {
+ return fmt.Errorf("marshal Daytona request body: %w", marshalErr)
+ }
+ bodyReader = bytes.NewReader(encoded)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader)
+ if err != nil {
+ return fmt.Errorf("create Daytona request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ if c.organizationID != "" {
+ req.Header.Set("X-Daytona-Organization-ID", c.organizationID)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("send Daytona %s request: %w", method, err)
+ }
+ defer func() {
+ closeErr := resp.Body.Close()
+ if closeErr != nil && err == nil {
+ err = fmt.Errorf("close Daytona response body: %w", closeErr)
+ }
+ }()
+ responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
+ if err != nil {
+ return fmt.Errorf("read Daytona response body: %w", err)
+ }
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return fmt.Errorf(
+ "Daytona %s %s returned status %d: %s",
+ method,
+ req.URL.Redacted(),
+ resp.StatusCode,
+ strings.TrimSpace(string(responseBody)),
+ )
+ }
+ if out == nil || len(bytes.TrimSpace(responseBody)) == 0 {
+ return nil
+ }
+ if err := json.Unmarshal(responseBody, out); err != nil {
+ return fmt.Errorf("decode Daytona response body: %w", err)
+ }
+ return nil
+}
+
+func (c daytonaValidationClient) endpoint(pathParts []string, query url.Values) (string, error) {
+ base, err := url.Parse(c.apiURL + "/")
+ if err != nil {
+ return "", fmt.Errorf("parse Daytona API URL: %w", err)
+ }
+ endpoint := base.JoinPath(pathParts...)
+ if query != nil {
+ endpoint.RawQuery = query.Encode()
+ }
+ return endpoint.String(), nil
+}
+
+func runPayloadChecks(t *testing.T, session *sshCatSession) {
+ t.Helper()
+
+ cases := []struct {
+ name string
+ payload []byte
+ }{
+ {name: "small-100B", payload: mustJSONPayload(t, 100)},
+ {name: "medium-10KB", payload: mustJSONPayload(t, 10*1024)},
+ {name: "large-100KB", payload: mustJSONPayload(t, 100*1024)},
+ {name: "newline-delimited-json", payload: newlineDelimitedJSONPayload(t)},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ result, err := session.roundTrip(tc.payload)
+ if err != nil {
+ t.Fatalf("SSH non-PTY cat round trip failed: %v", err)
+ }
+ assertCleanRoundTrip(t, tc.payload, result)
+ t.Logf("payload=%s bytes=%d latency=%s artifacts=none", tc.name, len(tc.payload), result.latency)
+ })
+ }
+}
+
+func runLatencyCheck(t *testing.T, session *sshCatSession) {
+ t.Helper()
+
+ payload := mustJSONPayload(t, 1024)
+ result, err := session.roundTrip(payload)
+ if err != nil {
+ t.Fatalf("SSH non-PTY latency check failed: %v", err)
+ }
+ assertCleanRoundTrip(t, payload, result)
+ if result.latency > latencyThreshold {
+ t.Fatalf("1KB round-trip latency = %s, want <= %s", result.latency, latencyThreshold)
+ }
+ t.Logf("payload=latency-1KB bytes=%d latency=%s threshold=%s", len(payload), result.latency, latencyThreshold)
+}
+
+func openSSHCatSession(ctx context.Context, target string) (*sshCatSession, int, error) {
+ deadline := time.Now().Add(sshReadyTimeout)
+ attempts := 0
+ var lastErr error
+ for {
+ attempts++
+ session, err := openSSHCatSessionAttempt(ctx, target)
+ if err == nil {
+ return session, attempts, nil
+ }
+ lastErr = err
+ if ctx.Err() != nil {
+ return nil, attempts, fmt.Errorf("wait for Daytona SSH readiness: %w", ctx.Err())
+ }
+ if time.Now().After(deadline) {
+ return nil, attempts, fmt.Errorf("wait for Daytona SSH readiness: %w", lastErr)
+ }
+ timer := time.NewTimer(sshReadyRetryInterval)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return nil, attempts, fmt.Errorf("wait for Daytona SSH readiness: %w", ctx.Err())
+ case <-timer.C:
+ }
+ }
+}
+
+func openSSHCatSessionAttempt(ctx context.Context, target string) (*sshCatSession, error) {
+ cmd := exec.CommandContext(ctx, "ssh", sshCommandArgs(target)...)
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, fmt.Errorf("open ssh stdin pipe: %w", err)
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("open ssh stdout pipe: %w", err)
+ }
+ stderr := &lockedBuffer{}
+ cmd.Stderr = stderr
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start ssh non-PTY cat session: %w", err)
+ }
+ if err := readReadyMarker(stdout); err != nil {
+ closeErr := stdin.Close()
+ return nil, waitWithSSHError(cmd, errors.Join(err, closeErr), stderr.String())
+ }
+ return &sshCatSession{cmd: cmd, stdin: stdin, stdout: stdout, stderr: stderr}, nil
+}
+
+func (s *sshCatSession) roundTrip(payload []byte) (sshRoundTripResult, error) {
+ started := time.Now()
+ writeErrCh := make(chan error, 1)
+ go func() {
+ writeErrCh <- writeAll(s.stdin, payload)
+ }()
+
+ output := make([]byte, len(payload))
+ _, readErr := io.ReadFull(s.stdout, output)
+ latency := time.Since(started)
+ writeErr := <-writeErrCh
+
+ if err := errors.Join(writeErr, readErr); err != nil {
+ return sshRoundTripResult{}, sshError(err, s.stderr.String())
+ }
+ return sshRoundTripResult{output: output, latency: latency}, nil
+}
+
+func (s *sshCatSession) Close() ([]byte, bool, error) {
+ if s == nil {
+ return nil, false, nil
+ }
+ closeErr := s.stdin.Close()
+ type closeResult struct {
+ trailing []byte
+ err error
+ }
+ done := make(chan closeResult, 1)
+ go func() {
+ trailing, readErr := io.ReadAll(s.stdout)
+ waitErr := s.cmd.Wait()
+ if err := errors.Join(closeErr, readErr, waitErr); err != nil {
+ done <- closeResult{trailing: trailing, err: sshError(err, s.stderr.String())}
+ return
+ }
+ done <- closeResult{trailing: trailing}
+ }()
+
+ timer := time.NewTimer(sshCloseTimeout)
+ defer timer.Stop()
+
+ select {
+ case result := <-done:
+ return result.trailing, false, result.err
+ case <-timer.C:
+ if s.cmd.Process != nil {
+ _ = s.cmd.Process.Kill()
+ }
+ result := <-done
+ return result.trailing, true, nil
+ }
+}
+
+func sshCommandArgs(target string) []string {
+ return []string{
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=10",
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "LogLevel=ERROR",
+ "-o", "RequestTTY=no",
+ "-T",
+ target,
+ fmt.Sprintf("sh -c 'printf %s; exec cat'", string(sshReadyMarker)),
+ }
+}
+
+func readReadyMarker(stdout io.Reader) error {
+ marker := make([]byte, len(sshReadyMarker))
+ if _, err := io.ReadFull(stdout, marker); err != nil {
+ return fmt.Errorf("read ssh ready marker: %w", err)
+ }
+ if !bytes.Equal(marker, sshReadyMarker) {
+ return fmt.Errorf("unexpected ssh ready marker %q", string(marker))
+ }
+ return nil
+}
+
+func waitWithSSHError(cmd *exec.Cmd, cause error, stderr string) error {
+ waitErr := cmd.Wait()
+ return sshError(errors.Join(cause, waitErr), stderr)
+}
+
+func sshError(err error, stderr string) error {
+ if strings.TrimSpace(stderr) == "" {
+ return err
+ }
+ return fmt.Errorf("%w; ssh stderr: %s", err, strings.TrimSpace(stderr))
+}
+
+func writeAll(writer io.Writer, payload []byte) error {
+ for len(payload) > 0 {
+ n, err := writer.Write(payload)
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return io.ErrShortWrite
+ }
+ payload = payload[n:]
+ }
+ return nil
+}
+
+func assertCleanRoundTrip(t *testing.T, want []byte, result sshRoundTripResult) {
+ t.Helper()
+
+ if containsTerminalArtifact(result.output) {
+ t.Fatalf("SSH stdout contains terminal artifact bytes: %s", previewBytes(result.output))
+ }
+ if !bytes.Equal(result.output, want) {
+ t.Fatalf("SSH stdout mismatch\ngot: %s\nwant: %s", previewBytes(result.output), previewBytes(want))
+ }
+}
+
+func containsTerminalArtifact(output []byte) bool {
+ return bytes.ContainsAny(output, "\x1b\r\b\x7f")
+}
+
+func mustJSONPayload(t *testing.T, size int) []byte {
+ t.Helper()
+
+ payload, err := jsonPayload(size)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return payload
+}
+
+func jsonPayload(size int) ([]byte, error) {
+ const prefix = `{"jsonrpc":"2.0","id":1,"method":"validate","params":{"padding":"`
+ const suffix = `"}}`
+
+ paddingSize := size - len(prefix) - len(suffix)
+ if paddingSize < 0 {
+ return nil, fmt.Errorf("JSON payload size %d is smaller than envelope size %d", size, len(prefix)+len(suffix))
+ }
+ payload := prefix + strings.Repeat("x", paddingSize) + suffix
+ return []byte(payload), nil
+}
+
+func newlineDelimitedJSONPayload(t *testing.T) []byte {
+ t.Helper()
+
+ messages := [][]byte{
+ mustJSONPayload(t, 128),
+ mustJSONPayload(t, 512),
+ mustJSONPayload(t, 1024),
+ }
+ return append(bytes.Join(messages, []byte("\n")), '\n')
+}
+
+func previewBytes(data []byte) string {
+ const maxPreviewBytes = 256
+ if len(data) <= maxPreviewBytes {
+ return fmt.Sprintf("%q", data)
+ }
+ return fmt.Sprintf("%q... (%d bytes)", data[:maxPreviewBytes], len(data))
+}
+
+func extractSandboxID(raw json.RawMessage) (string, error) {
+ var response map[string]any
+ if err := json.Unmarshal(raw, &response); err != nil {
+ return "", fmt.Errorf("decode create sandbox response: %w", err)
+ }
+ if sandboxID := stringField(response, "id", "sandboxId", "sandbox_id", "name"); sandboxID != "" {
+ return sandboxID, nil
+ }
+ for _, nestedKey := range []string{"sandbox", "data", "result"} {
+ nested, ok := response[nestedKey].(map[string]any)
+ if !ok {
+ continue
+ }
+ if sandboxID := stringField(nested, "id", "sandboxId", "sandbox_id", "name"); sandboxID != "" {
+ return sandboxID, nil
+ }
+ }
+ return "", fmt.Errorf("missing sandbox identifier in response keys: %v", mapKeys(response))
+}
+
+func stringField(values map[string]any, keys ...string) string {
+ for _, key := range keys {
+ value, ok := values[key].(string)
+ if ok && strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func mapKeys(values map[string]any) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ return keys
+}
+
+func daytonaSSHHost() string {
+ host := strings.TrimSpace(os.Getenv(daytonaSSHHostEnv))
+ if host == "" {
+ return defaultDaytonaSSHHost
+ }
+ return host
+}
diff --git a/internal/environment/daytona/state.go b/internal/environment/daytona/state.go
new file mode 100644
index 000000000..204f31145
--- /dev/null
+++ b/internal/environment/daytona/state.go
@@ -0,0 +1,89 @@
+package daytona
+
+import (
+ "encoding/json"
+ "fmt"
+ "maps"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+const (
+ providerStateVersion = 1
+ defaultAPIURL = "https://app.daytona.io/api"
+ defaultSSHHost = "ssh.app.daytona.io"
+ defaultRuntimeRoot = "/home/daytona/workspace"
+)
+
+type providerState struct {
+ Version int `json:"version"`
+ SandboxID string `json:"sandbox_id"`
+ SandboxName string `json:"sandbox_name,omitempty"`
+ APIURL string `json:"api_url,omitempty"`
+ SSHHost string `json:"ssh_host,omitempty"`
+ LocalRootDir string `json:"local_root_dir"`
+ LocalAdditionalDirs []string `json:"local_additional_dirs,omitempty"`
+ RuntimeRootDir string `json:"runtime_root_dir"`
+ RuntimeAdditionalDirs []string `json:"runtime_additional_dirs,omitempty"`
+ Persistence environment.PersistenceMode `json:"persistence"`
+ StartupSource environment.DaytonaStartupSource
+ StartupRef string `json:"startup_ref,omitempty"`
+ SSHAccessExpiresAt *time.Time `json:"ssh_access_expires_at,omitempty"`
+ PreparedAt time.Time `json:"prepared_at"`
+}
+
+func decodeProviderState(raw json.RawMessage) (providerState, error) {
+ if len(raw) == 0 {
+ return providerState{}, nil
+ }
+ var state providerState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ return providerState{}, fmt.Errorf("environment/daytona: decode provider state: %w", err)
+ }
+ return state, nil
+}
+
+func encodeProviderState(state providerState) (json.RawMessage, error) {
+ state.Version = providerStateVersion
+ raw, err := json.Marshal(state)
+ if err != nil {
+ return nil, fmt.Errorf("environment/daytona: encode provider state: %w", err)
+ }
+ return json.RawMessage(raw), nil
+}
+
+func normalizeAPIURL(apiURL string) string {
+ apiURL = strings.TrimSpace(apiURL)
+ if apiURL == "" {
+ return defaultAPIURL
+ }
+ return strings.TrimRight(apiURL, "/")
+}
+
+func normalizeSSHHost(host string) string {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return defaultSSHHost
+ }
+ return host
+}
+
+func cloneStrings(values []string) []string {
+ if values == nil {
+ return nil
+ }
+ cloned := make([]string, len(values))
+ copy(cloned, values)
+ return cloned
+}
+
+func cloneStringMap(values map[string]string) map[string]string {
+ if values == nil {
+ return nil
+ }
+ cloned := make(map[string]string, len(values))
+ maps.Copy(cloned, values)
+ return cloned
+}
diff --git a/internal/environment/daytona/sync.go b/internal/environment/daytona/sync.go
new file mode 100644
index 000000000..bebcc18c8
--- /dev/null
+++ b/internal/environment/daytona/sync.go
@@ -0,0 +1,233 @@
+package daytona
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+func (p *daytonaProvider) SyncToRuntime(
+ ctx context.Context,
+ state environment.SessionState,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if ctx == nil {
+ return environment.SyncResult{}, fmt.Errorf("environment/daytona: sync to runtime context is required")
+ }
+ if state.Backend != environment.BackendDaytona {
+ return environment.SyncResult{}, fmt.Errorf("environment/daytona: sync to runtime backend = %q", state.Backend)
+ }
+ providerState, err := decodeProviderState(state.ProviderState)
+ if err != nil {
+ return environment.SyncResult{}, err
+ }
+ if providerState.LocalRootDir == "" || providerState.RuntimeRootDir == "" {
+ return environment.SyncResult{}, fmt.Errorf("environment/daytona: sync to runtime missing root mapping")
+ }
+ roots := append(
+ []syncRoot{{local: providerState.LocalRootDir, runtime: providerState.RuntimeRootDir}},
+ additionalSyncRoots(providerState.LocalAdditionalDirs, providerState.RuntimeAdditionalDirs)...,
+ )
+ sandbox := sandboxInfo{
+ ID: providerState.SandboxID,
+ APIURL: providerState.APIURL,
+ SSHHost: providerState.SSHHost,
+ SSHAccessExpiresAt: state.SSHAccessExpiresAt,
+ }
+ result := environment.SyncResult{}
+ for _, root := range roots {
+ stats, err := p.syncOneToRuntime(ctx, sandbox, root, opts)
+ result.FilesSynced += stats.FilesSynced
+ result.BytesTransferred += stats.BytesTransferred
+ if err != nil {
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+ }
+ return result, nil
+}
+
+func (p *daytonaProvider) SyncFromRuntime(
+ ctx context.Context,
+ state environment.SessionState,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if ctx == nil {
+ return environment.SyncResult{}, fmt.Errorf("environment/daytona: sync from runtime context is required")
+ }
+ if state.Backend != environment.BackendDaytona {
+ return environment.SyncResult{}, fmt.Errorf(
+ "environment/daytona: sync from runtime backend = %q",
+ state.Backend,
+ )
+ }
+ providerState, err := decodeProviderState(state.ProviderState)
+ if err != nil {
+ return environment.SyncResult{}, err
+ }
+ if providerState.LocalRootDir == "" || providerState.RuntimeRootDir == "" {
+ return environment.SyncResult{}, fmt.Errorf("environment/daytona: sync from runtime missing root mapping")
+ }
+ roots := append(
+ []syncRoot{{local: providerState.LocalRootDir, runtime: providerState.RuntimeRootDir}},
+ additionalSyncRoots(providerState.LocalAdditionalDirs, providerState.RuntimeAdditionalDirs)...,
+ )
+ sandbox := sandboxInfo{
+ ID: providerState.SandboxID,
+ APIURL: providerState.APIURL,
+ SSHHost: providerState.SSHHost,
+ SSHAccessExpiresAt: state.SSHAccessExpiresAt,
+ }
+ result := environment.SyncResult{}
+ for _, root := range roots {
+ stats, err := p.syncOneFromRuntime(ctx, sandbox, root, opts)
+ result.FilesSynced += stats.FilesSynced
+ result.BytesTransferred += stats.BytesTransferred
+ if err != nil {
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+ }
+ return result, nil
+}
+
+type syncRoot struct {
+ local string
+ runtime string
+}
+
+func additionalSyncRoots(localDirs []string, runtimeDirs []string) []syncRoot {
+ limit := min(len(runtimeDirs), len(localDirs))
+ roots := make([]syncRoot, 0, limit)
+ for i := range limit {
+ roots = append(roots, syncRoot{local: localDirs[i], runtime: runtimeDirs[i]})
+ }
+ return roots
+}
+
+func (p *daytonaProvider) syncOneToRuntime(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ root syncRoot,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ archive, stats, err := buildTarArchive(ctx, filepath.Clean(root.local), opts.ExcludePatterns)
+ if err != nil {
+ result := environment.SyncResult{}
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+ defer func() {
+ if closeErr := archive.Close(); closeErr != nil {
+ p.logger.Warn("environment/daytona: close tar archive temp file failed", "error", closeErr)
+ }
+ if removeErr := os.Remove(archive.Name()); removeErr != nil {
+ p.logger.Warn("environment/daytona: remove tar archive temp file failed", "error", removeErr)
+ }
+ }()
+ info, err := archive.Stat()
+ if err != nil {
+ result := environment.SyncResult{}
+ err = fmt.Errorf("environment/daytona: stat tar archive temp file: %w", err)
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+
+ session, err := p.shellTransport.Dial(ctx, sandbox, remoteExtractCommand(root.runtime, info.Size()))
+ if err != nil {
+ return environment.SyncResult{}, fmt.Errorf(
+ "environment/daytona: open sync-to-runtime SSH stream for %q: %w",
+ root.runtime,
+ err,
+ )
+ }
+ _, writeErr := io.Copy(session, archive)
+ waitErr := session.Wait()
+ if waitErr != nil {
+ waitErr = fmt.Errorf(
+ "environment/daytona: remote extract %q failed: %w stderr=%q",
+ root.runtime,
+ waitErr,
+ session.Stderr(),
+ )
+ }
+ if err := session.Close(); err != nil {
+ p.logger.Warn("environment/daytona: close sync-to-runtime SSH session failed", "error", err)
+ }
+ result := environment.SyncResult{FilesSynced: stats.Files, BytesTransferred: stats.Bytes}
+ if err := joinSyncErrors(writeErr, waitErr); err != nil {
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+ p.logger.Info(
+ "environment/daytona: synced workspace to runtime",
+ "reason",
+ string(opts.Reason),
+ "local",
+ root.local,
+ "runtime",
+ root.runtime,
+ "files",
+ stats.Files,
+ "bytes",
+ stats.Bytes,
+ )
+ return result, nil
+}
+
+func (p *daytonaProvider) syncOneFromRuntime(
+ ctx context.Context,
+ sandbox sandboxInfo,
+ root syncRoot,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ session, err := p.shellTransport.Dial(ctx, sandbox, remoteArchiveCommand(root.runtime))
+ if err != nil {
+ return environment.SyncResult{}, fmt.Errorf(
+ "environment/daytona: open sync-from-runtime SSH stream for %q: %w",
+ root.runtime,
+ err,
+ )
+ }
+ stats, extractErr := extractTar(filepath.Clean(root.local), session)
+ waitErr := session.Wait()
+ if waitErr != nil {
+ waitErr = fmt.Errorf(
+ "environment/daytona: remote archive %q failed: %w stderr=%q",
+ root.runtime,
+ waitErr,
+ session.Stderr(),
+ )
+ }
+ if err := session.Close(); err != nil {
+ p.logger.Warn("environment/daytona: close sync-from-runtime SSH session failed", "error", err)
+ }
+ result := environment.SyncResult{FilesSynced: stats.Files, BytesTransferred: stats.Bytes}
+ if err := joinSyncErrors(extractErr, waitErr); err != nil {
+ result.Errors = append(result.Errors, err.Error())
+ return result, err
+ }
+ p.logger.Info(
+ "environment/daytona: synced runtime to workspace",
+ "reason",
+ string(opts.Reason),
+ "local",
+ root.local,
+ "runtime",
+ root.runtime,
+ "files",
+ stats.Files,
+ "bytes",
+ stats.Bytes,
+ )
+ return result, nil
+}
+
+func joinSyncErrors(errs ...error) error {
+ return errors.Join(errs...)
+}
diff --git a/internal/environment/daytona/tar.go b/internal/environment/daytona/tar.go
new file mode 100644
index 000000000..614daa343
--- /dev/null
+++ b/internal/environment/daytona/tar.go
@@ -0,0 +1,369 @@
+package daytona
+
+import (
+ "archive/tar"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "slices"
+ "strings"
+)
+
+var errUnsafeTarPath = errors.New("environment/daytona: unsafe tar path")
+
+type tarStats struct {
+ Files int
+ Bytes int64
+}
+
+type archiveEntry struct {
+ path string
+ rel string
+ info fs.FileInfo
+ link string
+}
+
+func writeTar(ctx context.Context, root string, dst io.Writer, excludePatterns []string) (tarStats, error) {
+ root = filepath.Clean(root)
+ entries, err := collectArchiveEntries(ctx, root, excludePatterns)
+ if err != nil {
+ return tarStats{}, err
+ }
+ writer := tar.NewWriter(dst)
+ defer writer.Close()
+ var stats tarStats
+ for _, entry := range entries {
+ header, err := tar.FileInfoHeader(entry.info, entry.link)
+ if err != nil {
+ return tarStats{}, fmt.Errorf("environment/daytona: build tar header for %q: %w", entry.path, err)
+ }
+ header.Name = entry.rel
+ if err := writer.WriteHeader(header); err != nil {
+ return tarStats{}, fmt.Errorf("environment/daytona: write tar header for %q: %w", entry.rel, err)
+ }
+ if entry.info.Mode().IsRegular() {
+ written, err := copyArchiveFile(writer, entry)
+ if err != nil {
+ return tarStats{}, err
+ }
+ stats.Files++
+ stats.Bytes += written
+ }
+ }
+ return stats, nil
+}
+
+func buildTarArchive(ctx context.Context, root string, excludePatterns []string) (*os.File, tarStats, error) {
+ file, err := os.CreateTemp("", "agh-daytona-sync-*.tar")
+ if err != nil {
+ return nil, tarStats{}, fmt.Errorf("environment/daytona: create tar archive temp file: %w", err)
+ }
+ stats, writeErr := writeTar(ctx, root, file, excludePatterns)
+ if writeErr != nil {
+ closeErr := file.Close()
+ removeErr := os.Remove(file.Name())
+ return nil, tarStats{}, errors.Join(writeErr, closeErr, removeErr)
+ }
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ closeErr := file.Close()
+ removeErr := os.Remove(file.Name())
+ return nil, tarStats{}, errors.Join(
+ fmt.Errorf("environment/daytona: rewind tar archive temp file: %w", err),
+ closeErr,
+ removeErr,
+ )
+ }
+ return file, stats, nil
+}
+
+func collectArchiveEntries(ctx context.Context, root string, excludePatterns []string) ([]archiveEntry, error) {
+ var entries []archiveEntry
+ err := filepath.WalkDir(root, func(filePath string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ if filePath == root {
+ return nil
+ }
+ rel, err := filepath.Rel(root, filePath)
+ if err != nil {
+ return fmt.Errorf("environment/daytona: make tar relative path: %w", err)
+ }
+ rel = filepath.ToSlash(rel)
+ if shouldExcludeArchivePath(rel, excludePatterns) {
+ if entry.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return fmt.Errorf("environment/daytona: stat %q for tar: %w", filePath, err)
+ }
+ var link string
+ if info.Mode()&os.ModeSymlink != 0 {
+ link, err = os.Readlink(filePath)
+ if err != nil {
+ return fmt.Errorf("environment/daytona: read symlink %q: %w", filePath, err)
+ }
+ }
+ entries = append(entries, archiveEntry{path: filePath, rel: rel, info: info, link: link})
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return entries, nil
+}
+
+func copyArchiveFile(writer io.Writer, entry archiveEntry) (int64, error) {
+ file, err := os.Open(entry.path)
+ if err != nil {
+ return 0, fmt.Errorf("environment/daytona: open %q for tar: %w", entry.path, err)
+ }
+ written, copyErr := io.Copy(writer, file)
+ closeErr := file.Close()
+ if copyErr != nil {
+ return 0, fmt.Errorf("environment/daytona: write tar file %q: %w", entry.rel, copyErr)
+ }
+ if closeErr != nil {
+ return 0, fmt.Errorf("environment/daytona: close tar source %q: %w", entry.path, closeErr)
+ }
+ return written, nil
+}
+
+func extractTar(root string, src io.Reader) (tarStats, error) {
+ root = filepath.Clean(root)
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return tarStats{}, fmt.Errorf("environment/daytona: create extract root %q: %w", root, err)
+ }
+ realRoot, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ return tarStats{}, fmt.Errorf("environment/daytona: evaluate extract root %q: %w", root, err)
+ }
+
+ reader := tar.NewReader(src)
+ var stats tarStats
+ for {
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ return stats, nil
+ }
+ if err != nil {
+ return tarStats{}, fmt.Errorf("environment/daytona: read tar header: %w", err)
+ }
+ if isArchiveRootMarker(header.Name) {
+ continue
+ }
+ entryStats, err := extractTarEntry(realRoot, header, reader)
+ if err != nil {
+ return tarStats{}, err
+ }
+ stats.Files += entryStats.Files
+ stats.Bytes += entryStats.Bytes
+ }
+}
+
+func extractTarEntry(realRoot string, header *tar.Header, reader io.Reader) (tarStats, error) {
+ target, err := archiveTargetPath(realRoot, header.Name)
+ if err != nil {
+ return tarStats{}, err
+ }
+ switch header.Typeflag {
+ case tar.TypeDir:
+ return tarStats{}, extractTarDirectory(target, header)
+ case tar.TypeReg:
+ written, err := extractTarRegularFile(realRoot, target, header, reader)
+ if err != nil {
+ return tarStats{}, err
+ }
+ return tarStats{Files: 1, Bytes: written}, nil
+ case tar.TypeSymlink:
+ return tarStats{}, extractTarSymlink(realRoot, target, header)
+ default:
+ return tarStats{}, fmt.Errorf(
+ "environment/daytona: unsupported tar entry %q mode %v",
+ header.Name,
+ header.Typeflag,
+ )
+ }
+}
+
+func archiveTargetPath(realRoot string, headerName string) (string, error) {
+ name, err := safeArchiveName(headerName)
+ if err != nil {
+ return "", err
+ }
+ target := filepath.Join(realRoot, filepath.FromSlash(name))
+ if !isWithinRoot(realRoot, target) {
+ return "", fmt.Errorf("%w: %q escapes %q", errUnsafeTarPath, headerName, realRoot)
+ }
+ return target, nil
+}
+
+func extractTarDirectory(target string, header *tar.Header) error {
+ if err := os.MkdirAll(target, modePerm(header.FileInfo().Mode(), 0o755)); err != nil {
+ return fmt.Errorf("environment/daytona: create directory %q: %w", target, err)
+ }
+ return nil
+}
+
+func extractTarRegularFile(realRoot string, target string, header *tar.Header, reader io.Reader) (int64, error) {
+ if err := ensureSafeParent(realRoot, target); err != nil {
+ return 0, err
+ }
+ file, err := os.OpenFile(
+ target,
+ os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
+ modePerm(header.FileInfo().Mode(), 0o600),
+ )
+ if err != nil {
+ return 0, fmt.Errorf("environment/daytona: create extracted file %q: %w", target, err)
+ }
+ written, copyErr := io.CopyN(file, reader, header.Size)
+ closeErr := file.Close()
+ if copyErr != nil {
+ return 0, fmt.Errorf("environment/daytona: write extracted file %q: %w", target, copyErr)
+ }
+ if closeErr != nil {
+ return 0, fmt.Errorf("environment/daytona: close extracted file %q: %w", target, closeErr)
+ }
+ return written, nil
+}
+
+func extractTarSymlink(realRoot string, target string, header *tar.Header) error {
+ if err := ensureSafeParent(realRoot, target); err != nil {
+ return err
+ }
+ linkTarget, err := safeSymlinkTarget(realRoot, target, header.Linkname)
+ if err != nil {
+ return err
+ }
+ if err := os.RemoveAll(target); err != nil {
+ return fmt.Errorf("environment/daytona: replace symlink %q: %w", target, err)
+ }
+ if err := os.Symlink(linkTarget, target); err != nil {
+ return fmt.Errorf("environment/daytona: create symlink %q: %w", target, err)
+ }
+ return nil
+}
+
+func safeArchiveName(name string) (string, error) {
+ cleaned := path.Clean(strings.TrimSpace(name))
+ if cleaned == "." || cleaned == "" {
+ return "", fmt.Errorf("%w: empty path", errUnsafeTarPath)
+ }
+ if path.IsAbs(cleaned) {
+ return "", fmt.Errorf("%w: absolute path %q", errUnsafeTarPath, name)
+ }
+ if slices.Contains(strings.Split(cleaned, "/"), "..") {
+ return "", fmt.Errorf("%w: traversal path %q", errUnsafeTarPath, name)
+ }
+ return cleaned, nil
+}
+
+func isArchiveRootMarker(name string) bool {
+ cleaned := path.Clean(strings.TrimSpace(name))
+ return cleaned == "." || cleaned == ""
+}
+
+func safeSymlinkTarget(root string, target string, linkName string) (string, error) {
+ if strings.TrimSpace(linkName) == "" {
+ return "", fmt.Errorf("%w: empty symlink target", errUnsafeTarPath)
+ }
+ if filepath.IsAbs(linkName) {
+ if !isWithinRoot(root, linkName) {
+ return "", fmt.Errorf("%w: symlink %q escapes %q", errUnsafeTarPath, linkName, root)
+ }
+ return linkName, nil
+ }
+ resolved := filepath.Clean(filepath.Join(filepath.Dir(target), linkName))
+ if !isWithinRoot(root, resolved) {
+ return "", fmt.Errorf("%w: symlink %q escapes %q", errUnsafeTarPath, linkName, root)
+ }
+ return linkName, nil
+}
+
+func ensureSafeParent(root string, target string) error {
+ parent := filepath.Dir(target)
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ return fmt.Errorf("environment/daytona: create parent %q: %w", parent, err)
+ }
+ realParent, err := filepath.EvalSymlinks(parent)
+ if err != nil {
+ return fmt.Errorf("environment/daytona: evaluate parent %q: %w", parent, err)
+ }
+ if !isWithinRoot(root, realParent) {
+ return fmt.Errorf("%w: parent %q escapes %q", errUnsafeTarPath, realParent, root)
+ }
+ if info, err := os.Lstat(target); err == nil && info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("%w: refusing to overwrite symlink %q", errUnsafeTarPath, target)
+ }
+ return nil
+}
+
+func shouldExcludeArchivePath(rel string, excludePatterns []string) bool {
+ for part := range strings.SplitSeq(rel, "/") {
+ switch part {
+ case "node_modules", "dist", "build", "target", ".cache", ".next", ".turbo":
+ return true
+ }
+ }
+ for _, pattern := range excludePatterns {
+ if archivePatternMatches(pattern, rel) {
+ return true
+ }
+ }
+ return false
+}
+
+func archivePatternMatches(pattern string, rel string) bool {
+ pattern = strings.TrimSpace(filepath.ToSlash(pattern))
+ if pattern == "" {
+ return false
+ }
+ rel = strings.TrimSpace(filepath.ToSlash(rel))
+ if rel == "" {
+ return false
+ }
+ trimmed := strings.TrimSuffix(pattern, "/")
+ if trimmed != "" && (rel == trimmed || strings.HasPrefix(rel, trimmed+"/")) {
+ return true
+ }
+ if matched, err := path.Match(pattern, rel); err == nil && matched {
+ return true
+ }
+ if !strings.Contains(pattern, "/") {
+ if matched, err := path.Match(pattern, path.Base(rel)); err == nil && matched {
+ return true
+ }
+ }
+ return false
+}
+
+func isWithinRoot(root string, target string) bool {
+ cleanRoot := filepath.Clean(root)
+ cleanTarget := filepath.Clean(target)
+ if cleanRoot == cleanTarget {
+ return true
+ }
+ return strings.HasPrefix(cleanTarget, cleanRoot+string(os.PathSeparator))
+}
+
+func modePerm(mode fs.FileMode, fallback fs.FileMode) fs.FileMode {
+ perm := mode.Perm()
+ if perm == 0 {
+ return fallback
+ }
+ return perm
+}
diff --git a/internal/environment/daytona/tar_test.go b/internal/environment/daytona/tar_test.go
new file mode 100644
index 000000000..df3f014f8
--- /dev/null
+++ b/internal/environment/daytona/tar_test.go
@@ -0,0 +1,153 @@
+package daytona
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestExtractTarRejectsUnsafeEntries(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ header tar.Header
+ body string
+ }{
+ {
+ name: "absolute path",
+ header: tar.Header{Name: "/tmp/evil", Mode: 0o600, Size: 1},
+ body: "x",
+ },
+ {
+ name: "parent traversal",
+ header: tar.Header{Name: "../evil", Mode: 0o600, Size: 1},
+ body: "x",
+ },
+ {
+ name: "symlink escape",
+ header: tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "../outside"},
+ },
+ {
+ name: "unsupported mode",
+ header: tar.Header{Name: "device", Typeflag: tar.TypeChar},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ archive := tarArchiveWithHeader(t, tc.header, tc.body)
+ if _, err := extractTar(t.TempDir(), bytes.NewReader(archive)); err == nil {
+ t.Fatal("extractTar() error = nil, want unsafe entry rejection")
+ }
+ })
+ }
+}
+
+func TestExtractTarRejectsExistingSymlinkEscape(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ outside := t.TempDir()
+ if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
+ t.Fatalf("Symlink() error = %v", err)
+ }
+ archive := makeTar(t, map[string]string{"link/escape.txt": "bad"})
+ if _, err := extractTar(root, bytes.NewReader(archive)); err == nil {
+ t.Fatal("extractTar() error = nil, want symlink parent rejection")
+ }
+}
+
+func TestWriteAndExtractTarRoundTripWithSymlinkAndExclusions(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ writeTestFile(t, filepath.Join(root, "file.txt"), "content")
+ writeTestFile(t, filepath.Join(root, "node_modules", "ignored.txt"), "ignored")
+ if err := os.Symlink("file.txt", filepath.Join(root, "link.txt")); err != nil {
+ t.Fatalf("Symlink() error = %v", err)
+ }
+
+ var archive bytes.Buffer
+ stats, err := writeTar(context.Background(), root, &archive, nil)
+ if err != nil {
+ t.Fatalf("writeTar() error = %v", err)
+ }
+ if stats.Files != 1 || stats.Bytes != int64(len("content")) {
+ t.Fatalf("writeTar() stats = %+v, want one regular file", stats)
+ }
+
+ dest := t.TempDir()
+ extracted, err := extractTar(dest, bytes.NewReader(archive.Bytes()))
+ if err != nil {
+ t.Fatalf("extractTar() error = %v", err)
+ }
+ if extracted.Files != 1 || extracted.Bytes != int64(len("content")) {
+ t.Fatalf("extractTar() stats = %+v, want one regular file", extracted)
+ }
+ assertFileContent(t, filepath.Join(dest, "file.txt"), "content")
+ if _, err := os.Stat(filepath.Join(dest, "node_modules", "ignored.txt")); !os.IsNotExist(err) {
+ t.Fatalf("excluded file exists or stat returned unexpected error: %v", err)
+ }
+ target, err := os.Readlink(filepath.Join(dest, "link.txt"))
+ if err != nil {
+ t.Fatalf("Readlink() error = %v", err)
+ }
+ if target != "file.txt" {
+ t.Fatalf("Readlink() = %q, want file.txt", target)
+ }
+}
+
+func TestExtractTarIgnoresRootEntry(t *testing.T) {
+ t.Parallel()
+
+ var archive bytes.Buffer
+ writer := tar.NewWriter(&archive)
+ if err := writer.WriteHeader(&tar.Header{Name: ".", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil {
+ t.Fatalf("WriteHeader(root) error = %v", err)
+ }
+ body := "content"
+ if err := writer.WriteHeader(&tar.Header{Name: "./file.txt", Mode: 0o600, Size: int64(len(body))}); err != nil {
+ t.Fatalf("WriteHeader(file) error = %v", err)
+ }
+ if _, err := writer.Write([]byte(body)); err != nil {
+ t.Fatalf("Write(file) error = %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ dest := t.TempDir()
+ stats, err := extractTar(dest, bytes.NewReader(archive.Bytes()))
+ if err != nil {
+ t.Fatalf("extractTar() error = %v", err)
+ }
+ if stats.Files != 1 || stats.Bytes != int64(len(body)) {
+ t.Fatalf("extractTar() stats = %+v, want one regular file", stats)
+ }
+ assertFileContent(t, filepath.Join(dest, "file.txt"), body)
+}
+
+func tarArchiveWithHeader(t *testing.T, header tar.Header, body string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ writer := tar.NewWriter(&buf)
+ if header.Size == 0 && body != "" {
+ header.Size = int64(len(body))
+ }
+ if err := writer.WriteHeader(&header); err != nil {
+ t.Fatalf("WriteHeader() error = %v", err)
+ }
+ if body != "" {
+ if _, err := writer.Write([]byte(body)); err != nil {
+ t.Fatalf("Write() error = %v", err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+ return buf.Bytes()
+}
diff --git a/internal/environment/daytona/tool_host.go b/internal/environment/daytona/tool_host.go
new file mode 100644
index 000000000..5cedda414
--- /dev/null
+++ b/internal/environment/daytona/tool_host.go
@@ -0,0 +1,305 @@
+package daytona
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+ "sync"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+var _ environment.ToolHost = (*daytonaToolHost)(nil)
+
+type daytonaToolHost struct {
+ sandbox sandbox
+ transport transport
+ sandboxInfo sandboxInfo
+ root string
+ permission config.PermissionMode
+ terminalsMu sync.Mutex
+ nextTerminal int
+ terminals map[string]*remoteTerminal
+ outputMaxBytes int
+}
+
+func newDaytonaToolHost(
+ sandbox sandbox,
+ transport transport,
+ info sandboxInfo,
+ root string,
+ permission config.PermissionMode,
+) (*daytonaToolHost, error) {
+ if sandbox == nil {
+ return nil, errors.New("environment/daytona: tool host sandbox is required")
+ }
+ if transport == nil {
+ return nil, errors.New("environment/daytona: tool host transport is required")
+ }
+ if permission == "" {
+ permission = config.PermissionModeApproveReads
+ }
+ if err := permission.Validate("permissions.mode"); err != nil {
+ return nil, err
+ }
+ return &daytonaToolHost{
+ sandbox: sandbox,
+ transport: transport,
+ sandboxInfo: info,
+ root: root,
+ permission: permission,
+ terminals: make(map[string]*remoteTerminal),
+ outputMaxBytes: 1 << 20,
+ }, nil
+}
+
+func (h *daytonaToolHost) ReadTextFile(ctx context.Context, requestPath string) (string, error) {
+ if err := h.Authorize(environment.PermissionOperationReadTextFile); err != nil {
+ return "", err
+ }
+ resolved, err := h.ResolvePath(requestPath)
+ if err != nil {
+ return "", err
+ }
+ content, err := h.sandbox.ReadFile(ctx, resolved)
+ if err != nil {
+ return "", err
+ }
+ return string(content), nil
+}
+
+func (h *daytonaToolHost) WriteTextFile(ctx context.Context, requestPath string, content string) error {
+ if err := h.Authorize(environment.PermissionOperationWriteTextFile); err != nil {
+ return err
+ }
+ resolved, err := h.ResolvePath(requestPath)
+ if err != nil {
+ return err
+ }
+ if err := h.sandbox.WriteFile(ctx, resolved, []byte(content)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (h *daytonaToolHost) ResolvePath(requestPath string) (string, error) {
+ target := strings.TrimSpace(requestPath)
+ if target == "" {
+ return "", errors.New("environment/daytona: request path is required")
+ }
+ if !path.IsAbs(target) {
+ target = path.Join(h.root, target)
+ }
+ cleaned := path.Clean(target)
+ if !isWithinRemoteRoot(h.root, cleaned) {
+ return "", fmt.Errorf("environment/daytona: path %q escapes runtime root %q", requestPath, h.root)
+ }
+ return cleaned, nil
+}
+
+func (h *daytonaToolHost) Authorize(op environment.PermissionOperation) error {
+ if h.isAllowed(op) {
+ return nil
+ }
+ return fmt.Errorf("environment/daytona: %s blocked by %s", op, h.permission)
+}
+
+func (h *daytonaToolHost) isAllowed(op environment.PermissionOperation) bool {
+ switch h.permission {
+ case config.PermissionModeApproveAll:
+ return true
+ case config.PermissionModeApproveReads:
+ return op == environment.PermissionOperationReadTextFile
+ case config.PermissionModeDenyAll:
+ return false
+ default:
+ return false
+ }
+}
+
+func (h *daytonaToolHost) PermissionDecision(
+ req acpsdk.RequestPermissionRequest,
+) (environment.PermissionDecision, bool) {
+ for _, location := range req.ToolCall.Locations {
+ if _, err := h.ResolvePath(location.Path); err != nil {
+ return environment.PermissionDecisionRejectOnce, false
+ }
+ }
+ switch h.permission {
+ case config.PermissionModeApproveAll:
+ return environment.PermissionDecisionAllowOnce, false
+ case config.PermissionModeApproveReads:
+ if req.ToolCall.Kind != nil && *req.ToolCall.Kind == acpsdk.ToolKindRead {
+ return environment.PermissionDecisionAllowOnce, false
+ }
+ return environment.PermissionDecisionPending, true
+ case config.PermissionModeDenyAll:
+ return environment.PermissionDecisionPending, true
+ default:
+ return environment.PermissionDecisionRejectOnce, false
+ }
+}
+
+func (h *daytonaToolHost) CreateTerminal(
+ ctx context.Context,
+ req acpsdk.CreateTerminalRequest,
+) (acpsdk.CreateTerminalResponse, error) {
+ if err := h.Authorize(environment.PermissionOperationCreateTerminal); err != nil {
+ return acpsdk.CreateTerminalResponse{}, err
+ }
+ command := remoteTerminalCommand(h.root, req)
+ session, err := h.transport.Dial(ctx, h.sandboxInfo, command)
+ if err != nil {
+ return acpsdk.CreateTerminalResponse{}, fmt.Errorf("environment/daytona: create terminal: %w", err)
+ }
+ terminal := &remoteTerminal{
+ session: session,
+ done: make(chan struct{}),
+ }
+ limit := h.outputMaxBytes
+ if req.OutputByteLimit != nil && *req.OutputByteLimit > 0 {
+ limit = *req.OutputByteLimit
+ }
+ h.terminalsMu.Lock()
+ h.nextTerminal++
+ id := fmt.Sprintf("daytona-%d", h.nextTerminal)
+ h.terminals[id] = terminal
+ h.terminalsMu.Unlock()
+
+ go terminal.capture(limit)
+ return acpsdk.CreateTerminalResponse{TerminalId: id}, nil
+}
+
+func (h *daytonaToolHost) KillTerminal(id string) error {
+ terminal, err := h.lookupTerminal(id)
+ if err != nil {
+ return err
+ }
+ return terminal.session.Close()
+}
+
+func (h *daytonaToolHost) TerminalOutput(id string) (string, error) {
+ terminal, err := h.lookupTerminal(id)
+ if err != nil {
+ return "", err
+ }
+ terminal.mu.Lock()
+ defer terminal.mu.Unlock()
+ return terminal.output.String(), nil
+}
+
+func (h *daytonaToolHost) WaitForTerminalExit(ctx context.Context, id string) (int, error) {
+ terminal, err := h.lookupTerminal(id)
+ if err != nil {
+ return 0, err
+ }
+ select {
+ case <-terminal.done:
+ case <-ctx.Done():
+ return 0, fmt.Errorf("environment/daytona: wait terminal %q: %w", id, ctx.Err())
+ }
+ return terminal.exitCode, terminal.err
+}
+
+func (h *daytonaToolHost) ReleaseTerminal(id string) error {
+ h.terminalsMu.Lock()
+ terminal, ok := h.terminals[id]
+ if ok {
+ delete(h.terminals, id)
+ }
+ h.terminalsMu.Unlock()
+ if !ok {
+ return fmt.Errorf("environment/daytona: terminal %q not found", id)
+ }
+ return terminal.session.Close()
+}
+
+func (h *daytonaToolHost) lookupTerminal(id string) (*remoteTerminal, error) {
+ h.terminalsMu.Lock()
+ defer h.terminalsMu.Unlock()
+ terminal, ok := h.terminals[id]
+ if !ok {
+ return nil, fmt.Errorf("environment/daytona: terminal %q not found", id)
+ }
+ return terminal, nil
+}
+
+type remoteTerminal struct {
+ session transportSession
+ mu sync.Mutex
+ output bytes.Buffer
+ done chan struct{}
+ exitCode int
+ err error
+}
+
+func (t *remoteTerminal) capture(limit int) {
+ _, readErr := ioCopyLimit(&t.output, t.session, limit, &t.mu)
+ waitErr := t.session.Wait()
+ stderr := t.session.Stderr()
+ t.mu.Lock()
+ if stderr != "" {
+ appendLimited(&t.output, []byte(stderr), limit)
+ }
+ t.mu.Unlock()
+ if readErr != nil && !errors.Is(readErr, context.Canceled) {
+ t.err = readErr
+ }
+ if waitErr != nil {
+ t.exitCode = 1
+ if t.err == nil {
+ t.err = waitErr
+ }
+ }
+ close(t.done)
+}
+
+func ioCopyLimit(dst *bytes.Buffer, src transportSession, limit int, mu *sync.Mutex) (int64, error) {
+ buf := make([]byte, 32*1024)
+ var total int64
+ for {
+ n, err := src.Read(buf)
+ if n > 0 {
+ mu.Lock()
+ appendLimited(dst, buf[:n], limit)
+ mu.Unlock()
+ total += int64(n)
+ }
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ return total, nil
+ }
+ return total, err
+ }
+ }
+}
+
+func appendLimited(dst *bytes.Buffer, data []byte, limit int) {
+ if limit <= 0 {
+ dst.Write(data)
+ return
+ }
+ dst.Write(data)
+ if dst.Len() <= limit {
+ return
+ }
+ trim := dst.Len() - limit
+ remaining := append([]byte(nil), dst.Bytes()[trim:]...)
+ dst.Reset()
+ dst.Write(remaining)
+}
+
+func isWithinRemoteRoot(root string, target string) bool {
+ cleanRoot := path.Clean(root)
+ cleanTarget := path.Clean(target)
+ if cleanRoot == cleanTarget {
+ return true
+ }
+ return strings.HasPrefix(cleanTarget, cleanRoot+"/")
+}
diff --git a/internal/environment/daytona/transport.go b/internal/environment/daytona/transport.go
new file mode 100644
index 000000000..304c142b1
--- /dev/null
+++ b/internal/environment/daytona/transport.go
@@ -0,0 +1,27 @@
+package daytona
+
+import (
+ "context"
+ "io"
+ "time"
+)
+
+type sandboxInfo struct {
+ ID string
+ APIURL string
+ SSHHost string
+ SSHAccessExpiresAt *time.Time
+}
+
+type transport interface {
+ Dial(ctx context.Context, sandbox sandboxInfo, command string) (transportSession, error)
+}
+
+type transportSession interface {
+ io.ReadWriteCloser
+ CloseWrite() error
+ Done() <-chan struct{}
+ Wait() error
+ Stop(ctx context.Context) error
+ Stderr() string
+}
diff --git a/internal/environment/local/provider.go b/internal/environment/local/provider.go
new file mode 100644
index 000000000..6dfe20afb
--- /dev/null
+++ b/internal/environment/local/provider.go
@@ -0,0 +1,171 @@
+// Package local implements the daemon-host execution environment provider.
+package local
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/acp"
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+var _ environment.Provider = (*localProvider)(nil)
+
+// Option customizes the local provider.
+type Option func(*localProvider)
+
+type localProvider struct {
+ logger *slog.Logger
+ stopTimeout time.Duration
+ permissionMode aghconfig.PermissionMode
+}
+
+// WithLogger directs provider-created launcher and tool-host diagnostics to logger.
+func WithLogger(logger *slog.Logger) Option {
+ return func(provider *localProvider) {
+ provider.logger = logger
+ }
+}
+
+// WithStopTimeout configures how long local launcher stop waits before escalation.
+func WithStopTimeout(timeout time.Duration) Option {
+ return func(provider *localProvider) {
+ provider.stopTimeout = timeout
+ }
+}
+
+// WithPermissionMode configures the local tool host permission policy.
+func WithPermissionMode(mode aghconfig.PermissionMode) Option {
+ return func(provider *localProvider) {
+ provider.permissionMode = mode
+ }
+}
+
+// NewProvider returns the local daemon-host environment provider.
+func NewProvider(opts ...Option) environment.Provider {
+ provider := &localProvider{
+ logger: slog.Default(),
+ permissionMode: aghconfig.PermissionModeApproveReads,
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(provider)
+ }
+ }
+ if provider.logger == nil {
+ provider.logger = slog.Default()
+ }
+ return provider
+}
+
+// NewRegistry returns a provider registry with local registered as the default backend.
+func NewRegistry(opts ...Option) (*environment.Registry, error) {
+ return environment.NewRegistry(NewProvider(opts...))
+}
+
+func (p *localProvider) Backend() environment.Backend {
+ return environment.BackendLocal
+}
+
+func (p *localProvider) Prepare(
+ ctx context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ if ctx == nil {
+ return environment.Prepared{}, errors.New("environment/local: prepare context is required")
+ }
+
+ launcher := acp.NewLocalLauncher(p.logger, p.stopTimeout)
+ toolHost, err := acp.NewLocalToolHost(ctx, req.LocalRootDir, p.permissionModeFor(req), p.logger)
+ if err != nil {
+ return environment.Prepared{}, fmt.Errorf("environment/local: create tool host: %w", err)
+ }
+
+ runtimeAdditionalDirs := cloneStrings(req.LocalAdditionalDirs)
+ launchAdditionalDirs := cloneStrings(runtimeAdditionalDirs)
+ agentEnv := cloneStrings(req.AgentEnv)
+ preparedState := environment.SessionState{
+ EnvironmentID: req.EnvironmentID,
+ Backend: environment.BackendLocal,
+ Profile: req.Environment.Profile,
+ InstanceID: strings.TrimSpace(req.InstanceID),
+ RuntimeRootDir: req.LocalRootDir,
+ RuntimeAdditionalDirs: cloneStrings(runtimeAdditionalDirs),
+ ProviderState: cloneRawMessage(req.ProviderState),
+ PreparedAt: time.Now().UTC(),
+ }
+
+ return environment.Prepared{
+ State: preparedState,
+ RuntimeRootDir: req.LocalRootDir,
+ RuntimeAdditionalDirs: runtimeAdditionalDirs,
+ Launcher: launcher,
+ Launch: environment.LaunchSpec{
+ Command: req.AgentCommand,
+ Cwd: req.LocalRootDir,
+ AdditionalDirs: launchAdditionalDirs,
+ Env: agentEnv,
+ },
+ ToolHost: toolHost,
+ }, nil
+}
+
+func (p *localProvider) SyncToRuntime(
+ ctx context.Context,
+ _ environment.SessionState,
+ _ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if ctx == nil {
+ return environment.SyncResult{}, errors.New("environment/local: sync to runtime context is required")
+ }
+ return environment.SyncResult{}, nil
+}
+
+func (p *localProvider) SyncFromRuntime(
+ ctx context.Context,
+ _ environment.SessionState,
+ _ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if ctx == nil {
+ return environment.SyncResult{}, errors.New("environment/local: sync from runtime context is required")
+ }
+ return environment.SyncResult{}, nil
+}
+
+func (p *localProvider) Destroy(ctx context.Context, _ environment.SessionState) error {
+ if ctx == nil {
+ return errors.New("environment/local: destroy context is required")
+ }
+ return nil
+}
+
+func (p *localProvider) permissionModeFor(req environment.PrepareRequest) aghconfig.PermissionMode {
+ if mode := strings.TrimSpace(req.Permissions); mode != "" {
+ return aghconfig.PermissionMode(mode)
+ }
+ return p.permissionMode
+}
+
+func cloneStrings(values []string) []string {
+ if values == nil {
+ return nil
+ }
+ cloned := make([]string, len(values))
+ copy(cloned, values)
+ return cloned
+}
+
+func cloneRawMessage(value json.RawMessage) json.RawMessage {
+ if value == nil {
+ return nil
+ }
+ cloned := make(json.RawMessage, len(value))
+ copy(cloned, value)
+ return cloned
+}
diff --git a/internal/environment/local/provider_test.go b/internal/environment/local/provider_test.go
new file mode 100644
index 000000000..1c6dcee71
--- /dev/null
+++ b/internal/environment/local/provider_test.go
@@ -0,0 +1,339 @@
+package local
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "reflect"
+ "testing"
+ "time"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+ "github.com/pedronauck/agh/internal/environment/providertest"
+)
+
+func TestLocalProviderBackend(t *testing.T) {
+ t.Parallel()
+
+ provider := NewProvider()
+ if got := provider.Backend(); got != environment.BackendLocal {
+ t.Fatalf("Backend() = %q, want %q", got, environment.BackendLocal)
+ }
+}
+
+func TestLocalProviderPrepareReturnsLocalRuntime(t *testing.T) {
+ t.Parallel()
+
+ req := newTestPrepareRequest(t)
+ provider := NewProvider(WithPermissionMode(aghconfig.PermissionModeDenyAll))
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ closePreparedToolHost(t, prepared)
+
+ assertPreparedMatchesRequest(t, prepared, req)
+ if prepared.State.PreparedAt.IsZero() {
+ t.Fatal("Prepared.State.PreparedAt is zero, want preparation timestamp")
+ }
+ if prepared.Launcher == nil {
+ t.Fatal("Prepared.Launcher = nil, want local launcher")
+ }
+ if prepared.ToolHost == nil {
+ t.Fatal("Prepared.ToolHost = nil, want local tool host")
+ }
+
+ if err := prepared.ToolHost.WriteTextFile(context.Background(), "nested/file.txt", "local content"); err != nil {
+ t.Fatalf("Prepared.ToolHost.WriteTextFile() error = %v", err)
+ }
+ content, err := prepared.ToolHost.ReadTextFile(context.Background(), "nested/file.txt")
+ if err != nil {
+ t.Fatalf("Prepared.ToolHost.ReadTextFile() error = %v", err)
+ }
+ if content != "local content" {
+ t.Fatalf("Prepared.ToolHost.ReadTextFile() = %q, want %q", content, "local content")
+ }
+}
+
+func TestLocalProviderPrepareClonesMutableInputs(t *testing.T) {
+ t.Parallel()
+
+ req := newTestPrepareRequest(t)
+ provider := NewProvider(WithPermissionMode(aghconfig.PermissionModeApproveAll))
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ closePreparedToolHost(t, prepared)
+
+ req.LocalAdditionalDirs[0] = "/mutated"
+ req.AgentEnv[0] = "MUTATED=true"
+ req.ProviderState[0] = '['
+
+ if got := prepared.RuntimeAdditionalDirs[0]; got == "/mutated" {
+ t.Fatal("Prepared.RuntimeAdditionalDirs aliased request LocalAdditionalDirs")
+ }
+ if got := prepared.Launch.AdditionalDirs[0]; got == "/mutated" {
+ t.Fatal("Prepared.Launch.AdditionalDirs aliased request LocalAdditionalDirs")
+ }
+ if got := prepared.Launch.Env[0]; got == "MUTATED=true" {
+ t.Fatal("Prepared.Launch.Env aliased request AgentEnv")
+ }
+ if got := string(prepared.State.ProviderState); got != `{"sandbox":"local"}` {
+ t.Fatalf("Prepared.State.ProviderState = %s, want original provider state", got)
+ }
+}
+
+func TestLocalProviderPreparePreservesNilMutableInputs(t *testing.T) {
+ t.Parallel()
+
+ req := newTestPrepareRequest(t)
+ req.LocalAdditionalDirs = nil
+ req.AgentEnv = nil
+ req.ProviderState = nil
+ provider := NewProvider(WithPermissionMode(aghconfig.PermissionModeApproveAll))
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err != nil {
+ t.Fatalf("Prepare() error = %v", err)
+ }
+ closePreparedToolHost(t, prepared)
+
+ if prepared.RuntimeAdditionalDirs != nil {
+ t.Fatalf("Prepared.RuntimeAdditionalDirs = %#v, want nil", prepared.RuntimeAdditionalDirs)
+ }
+ if prepared.State.RuntimeAdditionalDirs != nil {
+ t.Fatalf("Prepared.State.RuntimeAdditionalDirs = %#v, want nil", prepared.State.RuntimeAdditionalDirs)
+ }
+ if prepared.Launch.AdditionalDirs != nil {
+ t.Fatalf("Prepared.Launch.AdditionalDirs = %#v, want nil", prepared.Launch.AdditionalDirs)
+ }
+ if prepared.Launch.Env != nil {
+ t.Fatalf("Prepared.Launch.Env = %#v, want nil", prepared.Launch.Env)
+ }
+ if prepared.State.ProviderState != nil {
+ t.Fatalf("Prepared.State.ProviderState = %s, want nil", prepared.State.ProviderState)
+ }
+}
+
+func TestLocalProviderOptions(t *testing.T) {
+ t.Parallel()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ provider := NewProvider(
+ WithLogger(logger),
+ WithStopTimeout(25*time.Millisecond),
+ WithPermissionMode(aghconfig.PermissionModeApproveAll),
+ )
+ concrete, ok := provider.(*localProvider)
+ if !ok {
+ t.Fatalf("NewProvider() type = %T, want *localProvider", provider)
+ }
+ if concrete.logger != logger {
+ t.Fatal("WithLogger() did not set provider logger")
+ }
+ if concrete.stopTimeout != 25*time.Millisecond {
+ t.Fatalf("WithStopTimeout() = %v, want %v", concrete.stopTimeout, 25*time.Millisecond)
+ }
+ if concrete.permissionMode != aghconfig.PermissionModeApproveAll {
+ t.Fatalf(
+ "WithPermissionMode() = %q, want %q",
+ concrete.permissionMode,
+ aghconfig.PermissionModeApproveAll,
+ )
+ }
+
+ provider = NewProvider(WithLogger(nil))
+ concrete, ok = provider.(*localProvider)
+ if !ok {
+ t.Fatalf("NewProvider(WithLogger(nil)) type = %T, want *localProvider", provider)
+ }
+ if concrete.logger == nil {
+ t.Fatal("NewProvider(WithLogger(nil)) logger = nil, want default logger")
+ }
+}
+
+func TestLocalProviderPrepareReturnsToolHostErrors(t *testing.T) {
+ t.Parallel()
+
+ req := newTestPrepareRequest(t)
+ req.Permissions = "invalid"
+ provider := NewProvider()
+
+ prepared, err := provider.Prepare(context.Background(), req)
+ if err == nil {
+ closePreparedToolHost(t, prepared)
+ t.Fatal("Prepare() error = nil, want invalid permission mode error")
+ }
+}
+
+func TestLocalProviderNoopLifecycleMethods(t *testing.T) {
+ t.Parallel()
+
+ provider := NewProvider()
+ state := environment.SessionState{
+ EnvironmentID: "env-local",
+ Backend: environment.BackendLocal,
+ RuntimeRootDir: t.TempDir(),
+ RuntimeAdditionalDirs: []string{t.TempDir()},
+ }
+
+ if result, err := provider.SyncToRuntime(context.Background(), state, environment.SyncOptions{
+ Reason: environment.SyncReasonStart,
+ }); err != nil {
+ t.Fatalf("SyncToRuntime() error = %v", err)
+ } else if result.FilesSynced != 0 || result.BytesTransferred != 0 || len(result.Errors) != 0 {
+ t.Fatalf("SyncToRuntime() result = %#v, want empty result", result)
+ }
+ if result, err := provider.SyncFromRuntime(context.Background(), state, environment.SyncOptions{
+ Reason: environment.SyncReasonStop,
+ }); err != nil {
+ t.Fatalf("SyncFromRuntime() error = %v", err)
+ } else if result.FilesSynced != 0 || result.BytesTransferred != 0 || len(result.Errors) != 0 {
+ t.Fatalf("SyncFromRuntime() result = %#v, want empty result", result)
+ }
+ if err := provider.Destroy(context.Background(), state); err != nil {
+ t.Fatalf("Destroy() error = %v", err)
+ }
+}
+
+func TestLocalProviderRegistryResolvesLocalDefault(t *testing.T) {
+ t.Parallel()
+
+ registry, err := NewRegistry(WithPermissionMode(aghconfig.PermissionModeApproveAll))
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+
+ provider, err := registry.Provider(environment.BackendLocal)
+ if err != nil {
+ t.Fatalf("Provider(%q) error = %v", environment.BackendLocal, err)
+ }
+ if got := provider.Backend(); got != environment.BackendLocal {
+ t.Fatalf("Provider(%q).Backend() = %q, want %q", environment.BackendLocal, got, environment.BackendLocal)
+ }
+
+ defaultProvider, err := registry.DefaultProvider()
+ if err != nil {
+ t.Fatalf("DefaultProvider() error = %v", err)
+ }
+ if got := defaultProvider.Backend(); got != environment.BackendLocal {
+ t.Fatalf("DefaultProvider().Backend() = %q, want %q", got, environment.BackendLocal)
+ }
+}
+
+func TestLocalProviderLifecycleCompliance(t *testing.T) {
+ t.Parallel()
+
+ req := newTestPrepareRequest(t)
+ provider := NewProvider(WithPermissionMode(aghconfig.PermissionModeApproveAll))
+
+ prepared := providertest.RunLifecycle(t, providertest.LifecycleCase{
+ Provider: provider,
+ Backend: environment.BackendLocal,
+ PrepareRequest: req,
+ AssertPrepared: func(t *testing.T, prepared environment.Prepared) {
+ t.Helper()
+ assertPreparedMatchesRequest(t, prepared, req)
+ },
+ AssertFinalState: func(t *testing.T, state environment.SessionState) {
+ t.Helper()
+ if state.Backend != environment.BackendLocal {
+ t.Fatalf("final state backend = %q, want %q", state.Backend, environment.BackendLocal)
+ }
+ },
+ })
+ closePreparedToolHost(t, prepared)
+}
+
+func newTestPrepareRequest(t *testing.T) environment.PrepareRequest {
+ t.Helper()
+
+ return environment.PrepareRequest{
+ SessionID: "sess-local",
+ WorkspaceID: "workspace-local",
+ EnvironmentID: "env-local",
+ LocalRootDir: t.TempDir(),
+ LocalAdditionalDirs: []string{t.TempDir(), t.TempDir()},
+ Environment: environment.Resolved{
+ Profile: "local-dev",
+ Backend: environment.BackendLocal,
+ SyncMode: environment.SyncModeNone,
+ },
+ AgentCommand: "sh -c 'cat'",
+ AgentEnv: []string{"AGH_SESSION_ID=sess-local", "CUSTOM=value"},
+ Permissions: string(aghconfig.PermissionModeApproveAll),
+ ProviderState: json.RawMessage(`{"sandbox":"local"}`),
+ }
+}
+
+func assertPreparedMatchesRequest(
+ t *testing.T,
+ prepared environment.Prepared,
+ req environment.PrepareRequest,
+) {
+ t.Helper()
+
+ if prepared.RuntimeRootDir != req.LocalRootDir {
+ t.Fatalf("Prepared.RuntimeRootDir = %q, want %q", prepared.RuntimeRootDir, req.LocalRootDir)
+ }
+ if !reflect.DeepEqual(prepared.RuntimeAdditionalDirs, req.LocalAdditionalDirs) {
+ t.Fatalf(
+ "Prepared.RuntimeAdditionalDirs = %#v, want %#v",
+ prepared.RuntimeAdditionalDirs,
+ req.LocalAdditionalDirs,
+ )
+ }
+ if prepared.State.RuntimeRootDir != req.LocalRootDir {
+ t.Fatalf("Prepared.State.RuntimeRootDir = %q, want %q", prepared.State.RuntimeRootDir, req.LocalRootDir)
+ }
+ if !reflect.DeepEqual(prepared.State.RuntimeAdditionalDirs, req.LocalAdditionalDirs) {
+ t.Fatalf(
+ "Prepared.State.RuntimeAdditionalDirs = %#v, want %#v",
+ prepared.State.RuntimeAdditionalDirs,
+ req.LocalAdditionalDirs,
+ )
+ }
+ if prepared.State.EnvironmentID != req.EnvironmentID {
+ t.Fatalf("Prepared.State.EnvironmentID = %q, want %q", prepared.State.EnvironmentID, req.EnvironmentID)
+ }
+ if prepared.State.Backend != environment.BackendLocal {
+ t.Fatalf("Prepared.State.Backend = %q, want %q", prepared.State.Backend, environment.BackendLocal)
+ }
+ if prepared.State.Profile != req.Environment.Profile {
+ t.Fatalf("Prepared.State.Profile = %q, want %q", prepared.State.Profile, req.Environment.Profile)
+ }
+ if string(prepared.State.ProviderState) != string(req.ProviderState) {
+ t.Fatalf("Prepared.State.ProviderState = %s, want %s", prepared.State.ProviderState, req.ProviderState)
+ }
+ if prepared.Launch.Command != req.AgentCommand {
+ t.Fatalf("Prepared.Launch.Command = %q, want %q", prepared.Launch.Command, req.AgentCommand)
+ }
+ if prepared.Launch.Cwd != req.LocalRootDir {
+ t.Fatalf("Prepared.Launch.Cwd = %q, want %q", prepared.Launch.Cwd, req.LocalRootDir)
+ }
+ if !reflect.DeepEqual(prepared.Launch.AdditionalDirs, req.LocalAdditionalDirs) {
+ t.Fatalf(
+ "Prepared.Launch.AdditionalDirs = %#v, want %#v",
+ prepared.Launch.AdditionalDirs,
+ req.LocalAdditionalDirs,
+ )
+ }
+ if !reflect.DeepEqual(prepared.Launch.Env, req.AgentEnv) {
+ t.Fatalf("Prepared.Launch.Env = %#v, want %#v", prepared.Launch.Env, req.AgentEnv)
+ }
+}
+
+func closePreparedToolHost(t *testing.T, prepared environment.Prepared) {
+ t.Helper()
+
+ closer, ok := prepared.ToolHost.(interface{ Close() })
+ if !ok {
+ return
+ }
+ t.Cleanup(closer.Close)
+}
diff --git a/internal/environment/providertest/suite.go b/internal/environment/providertest/suite.go
new file mode 100644
index 000000000..429a76d59
--- /dev/null
+++ b/internal/environment/providertest/suite.go
@@ -0,0 +1,76 @@
+// Package providertest contains reusable provider conformance checks.
+package providertest
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+// LifecycleCase configures the shared Provider lifecycle compliance suite.
+type LifecycleCase struct {
+ Provider environment.Provider
+ Backend environment.Backend
+ PrepareRequest environment.PrepareRequest
+ AssertPrepared func(*testing.T, environment.Prepared)
+ AssertFinalState func(*testing.T, environment.SessionState)
+}
+
+// RunLifecycle exercises the common prepare, sync-to, sync-from, destroy lifecycle.
+func RunLifecycle(t *testing.T, tc LifecycleCase) environment.Prepared {
+ t.Helper()
+
+ prepared, err := runLifecycle(context.Background(), tc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tc.AssertPrepared != nil {
+ tc.AssertPrepared(t, prepared)
+ }
+ if tc.AssertFinalState != nil {
+ tc.AssertFinalState(t, prepared.State)
+ }
+
+ return prepared
+}
+
+func runLifecycle(ctx context.Context, tc LifecycleCase) (environment.Prepared, error) {
+ if tc.Provider == nil {
+ return environment.Prepared{}, errors.New("provider = nil, want provider")
+ }
+ if tc.Backend != "" {
+ if got := tc.Provider.Backend(); got != tc.Backend {
+ return environment.Prepared{}, fmt.Errorf("Provider.Backend() = %q, want %q", got, tc.Backend)
+ }
+ }
+
+ prepared, err := tc.Provider.Prepare(ctx, tc.PrepareRequest)
+ if err != nil {
+ return environment.Prepared{}, fmt.Errorf("Provider.Prepare() error = %w", err)
+ }
+ if prepared.Launcher == nil {
+ return environment.Prepared{}, errors.New("Prepared.Launcher = nil, want launcher")
+ }
+ if prepared.ToolHost == nil {
+ return environment.Prepared{}, errors.New("Prepared.ToolHost = nil, want tool host")
+ }
+
+ if _, err := tc.Provider.SyncToRuntime(ctx, prepared.State, environment.SyncOptions{
+ Reason: environment.SyncReasonStart,
+ }); err != nil {
+ return environment.Prepared{}, fmt.Errorf("Provider.SyncToRuntime() error = %w", err)
+ }
+ if _, err := tc.Provider.SyncFromRuntime(ctx, prepared.State, environment.SyncOptions{
+ Reason: environment.SyncReasonStop,
+ }); err != nil {
+ return environment.Prepared{}, fmt.Errorf("Provider.SyncFromRuntime() error = %w", err)
+ }
+ if err := tc.Provider.Destroy(ctx, prepared.State); err != nil {
+ return environment.Prepared{}, fmt.Errorf("Provider.Destroy() error = %w", err)
+ }
+
+ return prepared, nil
+}
diff --git a/internal/environment/providertest/suite_test.go b/internal/environment/providertest/suite_test.go
new file mode 100644
index 000000000..17227442c
--- /dev/null
+++ b/internal/environment/providertest/suite_test.go
@@ -0,0 +1,265 @@
+package providertest
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+ "github.com/pedronauck/agh/internal/environment"
+)
+
+func TestRunLifecycleExercisesProviderContract(t *testing.T) {
+ t.Parallel()
+
+ provider := &suiteTestProvider{backend: environment.BackendLocal}
+ prepared := RunLifecycle(t, LifecycleCase{
+ Provider: provider,
+ Backend: environment.BackendLocal,
+ PrepareRequest: environment.PrepareRequest{
+ EnvironmentID: "env-suite",
+ LocalRootDir: t.TempDir(),
+ Environment: environment.Resolved{Backend: environment.BackendLocal},
+ },
+ AssertPrepared: func(t *testing.T, prepared environment.Prepared) {
+ t.Helper()
+ if prepared.State.EnvironmentID != "env-suite" {
+ t.Fatalf("prepared environment id = %q, want env-suite", prepared.State.EnvironmentID)
+ }
+ },
+ AssertFinalState: func(t *testing.T, state environment.SessionState) {
+ t.Helper()
+ if state.Backend != environment.BackendLocal {
+ t.Fatalf("final state backend = %q, want %q", state.Backend, environment.BackendLocal)
+ }
+ },
+ })
+
+ if prepared.State.EnvironmentID != "env-suite" {
+ t.Fatalf("RunLifecycle() state environment id = %q, want env-suite", prepared.State.EnvironmentID)
+ }
+ if !provider.syncedToRuntime {
+ t.Fatal("RunLifecycle() did not call SyncToRuntime")
+ }
+ if !provider.syncedFromRuntime {
+ t.Fatal("RunLifecycle() did not call SyncFromRuntime")
+ }
+ if !provider.destroyed {
+ t.Fatal("RunLifecycle() did not call Destroy")
+ }
+}
+
+func TestRunLifecycleDetectsInvalidProviderCases(t *testing.T) {
+ t.Parallel()
+
+ wantErr := errors.New("provider failure")
+ tests := []struct {
+ name string
+ provider environment.Provider
+ backend environment.Backend
+ wantText string
+ }{
+ {name: "nil provider", provider: nil, backend: environment.BackendLocal, wantText: "provider = nil"},
+ {
+ name: "backend mismatch",
+ provider: &suiteTestProvider{backend: environment.BackendDaytona},
+ backend: environment.BackendLocal,
+ wantText: "Provider.Backend()",
+ },
+ {
+ name: "prepare error",
+ provider: &suiteTestProvider{backend: environment.BackendLocal, prepareErr: wantErr},
+ backend: environment.BackendLocal,
+ wantText: "Provider.Prepare()",
+ },
+ {
+ name: "missing launcher",
+ provider: &suiteTestProvider{
+ backend: environment.BackendLocal,
+ launcher: nil,
+ toolHost: suiteTestToolHost{},
+ },
+ backend: environment.BackendLocal,
+ wantText: "Prepared.Launcher",
+ },
+ {
+ name: "missing tool host",
+ provider: &suiteTestProvider{
+ backend: environment.BackendLocal,
+ launcher: suiteTestLauncher{},
+ toolHost: nil,
+ },
+ backend: environment.BackendLocal,
+ wantText: "Prepared.ToolHost",
+ },
+ {
+ name: "sync to runtime error",
+ provider: &suiteTestProvider{backend: environment.BackendLocal, syncToErr: wantErr},
+ backend: environment.BackendLocal,
+ wantText: "Provider.SyncToRuntime()",
+ },
+ {
+ name: "sync from runtime error",
+ provider: &suiteTestProvider{backend: environment.BackendLocal, syncFromErr: wantErr},
+ backend: environment.BackendLocal,
+ wantText: "Provider.SyncFromRuntime()",
+ },
+ {
+ name: "destroy error",
+ provider: &suiteTestProvider{backend: environment.BackendLocal, destroyErr: wantErr},
+ backend: environment.BackendLocal,
+ wantText: "Provider.Destroy()",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := runLifecycle(context.Background(), LifecycleCase{
+ Provider: tt.provider,
+ Backend: tt.backend,
+ PrepareRequest: environment.PrepareRequest{
+ EnvironmentID: "env-suite",
+ LocalRootDir: t.TempDir(),
+ },
+ })
+ if err == nil {
+ t.Fatal("runLifecycle() error = nil, want error")
+ }
+ if !strings.Contains(err.Error(), tt.wantText) {
+ t.Fatalf("runLifecycle() error = %q, want text %q", err, tt.wantText)
+ }
+ })
+ }
+}
+
+type suiteTestProvider struct {
+ backend environment.Backend
+ prepareErr error
+ launcher environment.Launcher
+ toolHost environment.ToolHost
+ syncToErr error
+ syncFromErr error
+ destroyErr error
+ syncedToRuntime bool
+ syncedFromRuntime bool
+ destroyed bool
+}
+
+func (p *suiteTestProvider) Backend() environment.Backend {
+ return p.backend
+}
+
+func (p *suiteTestProvider) Prepare(
+ _ context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ if p.prepareErr != nil {
+ return environment.Prepared{}, p.prepareErr
+ }
+ launcher := p.launcher
+ if launcher == nil && p.toolHost == nil {
+ launcher = suiteTestLauncher{}
+ }
+ toolHost := p.toolHost
+ if toolHost == nil && p.launcher == nil {
+ toolHost = suiteTestToolHost{}
+ }
+ return environment.Prepared{
+ State: environment.SessionState{
+ EnvironmentID: req.EnvironmentID,
+ Backend: environment.BackendLocal,
+ RuntimeRootDir: req.LocalRootDir,
+ },
+ RuntimeRootDir: req.LocalRootDir,
+ Launcher: launcher,
+ ToolHost: toolHost,
+ }, nil
+}
+
+func (p *suiteTestProvider) SyncToRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if p.syncToErr != nil {
+ return environment.SyncResult{}, p.syncToErr
+ }
+ p.syncedToRuntime = true
+ return environment.SyncResult{}, nil
+}
+
+func (p *suiteTestProvider) SyncFromRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ if p.syncFromErr != nil {
+ return environment.SyncResult{}, p.syncFromErr
+ }
+ p.syncedFromRuntime = true
+ return environment.SyncResult{}, nil
+}
+
+func (p *suiteTestProvider) Destroy(context.Context, environment.SessionState) error {
+ if p.destroyErr != nil {
+ return p.destroyErr
+ }
+ p.destroyed = true
+ return nil
+}
+
+type suiteTestLauncher struct{}
+
+func (suiteTestLauncher) Launch(context.Context, environment.LaunchSpec) (environment.Handle, error) {
+ return nil, nil
+}
+
+type suiteTestToolHost struct{}
+
+func (suiteTestToolHost) ReadTextFile(context.Context, string) (string, error) {
+ return "", nil
+}
+
+func (suiteTestToolHost) WriteTextFile(context.Context, string, string) error {
+ return nil
+}
+
+func (suiteTestToolHost) ResolvePath(path string) (string, error) {
+ return path, nil
+}
+
+func (suiteTestToolHost) Authorize(environment.PermissionOperation) error {
+ return nil
+}
+
+func (suiteTestToolHost) PermissionDecision(
+ acpsdk.RequestPermissionRequest,
+) (environment.PermissionDecision, bool) {
+ return environment.PermissionDecisionAllowOnce, false
+}
+
+func (suiteTestToolHost) CreateTerminal(
+ context.Context,
+ acpsdk.CreateTerminalRequest,
+) (acpsdk.CreateTerminalResponse, error) {
+ return acpsdk.CreateTerminalResponse{}, nil
+}
+
+func (suiteTestToolHost) KillTerminal(string) error {
+ return nil
+}
+
+func (suiteTestToolHost) TerminalOutput(string) (string, error) {
+ return "", nil
+}
+
+func (suiteTestToolHost) WaitForTerminalExit(context.Context, string) (int, error) {
+ return 0, nil
+}
+
+func (suiteTestToolHost) ReleaseTerminal(string) error {
+ return nil
+}
diff --git a/internal/environment/registry.go b/internal/environment/registry.go
new file mode 100644
index 000000000..ad6a04516
--- /dev/null
+++ b/internal/environment/registry.go
@@ -0,0 +1,82 @@
+package environment
+
+import (
+ "errors"
+ "fmt"
+ "maps"
+)
+
+const (
+ // DefaultBackend is the execution backend used when no profile selects one.
+ DefaultBackend = BackendLocal
+)
+
+var (
+ // ErrNilProvider reports an attempt to register a nil provider.
+ ErrNilProvider = errors.New("environment: provider is nil")
+ // ErrInvalidProviderBackend reports that a provider returned an unknown backend.
+ ErrInvalidProviderBackend = errors.New("environment: provider backend is invalid")
+ // ErrProviderNotRegistered reports that no provider is registered for a backend.
+ ErrProviderNotRegistered = errors.New("environment: provider not registered")
+)
+
+// Registry resolves environment providers by backend.
+type Registry struct {
+ providers map[Backend]Provider
+}
+
+// NewRegistry constructs a provider registry populated with the supplied providers.
+func NewRegistry(providers ...Provider) (*Registry, error) {
+ registry := &Registry{
+ providers: make(map[Backend]Provider, len(providers)),
+ }
+ for _, provider := range providers {
+ if err := registry.Register(provider); err != nil {
+ return nil, err
+ }
+ }
+ return registry, nil
+}
+
+// Register adds or replaces the provider for its backend.
+func (r *Registry) Register(provider Provider) error {
+ if provider == nil {
+ return ErrNilProvider
+ }
+ backend := provider.Backend()
+ if !backend.Valid() {
+ return fmt.Errorf("%w: %q", ErrInvalidProviderBackend, backend)
+ }
+ if r.providers == nil {
+ r.providers = make(map[Backend]Provider)
+ }
+ r.providers[backend] = provider
+ return nil
+}
+
+// Provider returns the provider registered for backend.
+func (r *Registry) Provider(backend Backend) (Provider, error) {
+ if r == nil || r.providers == nil {
+ return nil, fmt.Errorf("%w: %q", ErrProviderNotRegistered, backend)
+ }
+ provider, ok := r.providers[backend]
+ if !ok || provider == nil {
+ return nil, fmt.Errorf("%w: %q", ErrProviderNotRegistered, backend)
+ }
+ return provider, nil
+}
+
+// DefaultProvider returns the provider registered for the default backend.
+func (r *Registry) DefaultProvider() (Provider, error) {
+ return r.Provider(DefaultBackend)
+}
+
+// Providers returns a snapshot of registered providers keyed by backend.
+func (r *Registry) Providers() map[Backend]Provider {
+ if r == nil || len(r.providers) == 0 {
+ return map[Backend]Provider{}
+ }
+ providers := make(map[Backend]Provider, len(r.providers))
+ maps.Copy(providers, r.providers)
+ return providers
+}
diff --git a/internal/environment/registry_test.go b/internal/environment/registry_test.go
new file mode 100644
index 000000000..3a4986046
--- /dev/null
+++ b/internal/environment/registry_test.go
@@ -0,0 +1,149 @@
+package environment
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestRegistryProviderReturnsRegisteredProvider(t *testing.T) {
+ t.Parallel()
+
+ want := registryTestProvider{backend: BackendLocal}
+ registry, err := NewRegistry(want)
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+
+ got, err := registry.Provider(BackendLocal)
+ if err != nil {
+ t.Fatalf("Provider(%q) error = %v", BackendLocal, err)
+ }
+ if got.Backend() != BackendLocal {
+ t.Fatalf("Provider(%q).Backend() = %q, want %q", BackendLocal, got.Backend(), BackendLocal)
+ }
+
+ defaultProvider, err := registry.DefaultProvider()
+ if err != nil {
+ t.Fatalf("DefaultProvider() error = %v", err)
+ }
+ if defaultProvider.Backend() != DefaultBackend {
+ t.Fatalf("DefaultProvider().Backend() = %q, want %q", defaultProvider.Backend(), DefaultBackend)
+ }
+}
+
+func TestRegistryProviderReturnsErrorForUnregisteredBackend(t *testing.T) {
+ t.Parallel()
+
+ registry, err := NewRegistry(registryTestProvider{backend: BackendLocal})
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+
+ _, err = registry.Provider(BackendDaytona)
+ if !errors.Is(err, ErrProviderNotRegistered) {
+ t.Fatalf("Provider(%q) error = %v, want ErrProviderNotRegistered", BackendDaytona, err)
+ }
+
+ var nilRegistry *Registry
+ _, err = nilRegistry.Provider(BackendLocal)
+ if !errors.Is(err, ErrProviderNotRegistered) {
+ t.Fatalf("nil Registry.Provider() error = %v, want ErrProviderNotRegistered", err)
+ }
+}
+
+func TestRegistryRejectsInvalidProviders(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ provider Provider
+ wantErr error
+ }{
+ {name: "nil provider", provider: nil, wantErr: ErrNilProvider},
+ {
+ name: "invalid backend",
+ provider: registryTestProvider{backend: Backend("docker")},
+ wantErr: ErrInvalidProviderBackend,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := NewRegistry(tt.provider)
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("NewRegistry() error = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestRegistryRegisterInitializesEmptyRegistry(t *testing.T) {
+ t.Parallel()
+
+ var registry Registry
+ if err := registry.Register(registryTestProvider{backend: BackendLocal}); err != nil {
+ t.Fatalf("Register() error = %v", err)
+ }
+ provider, err := registry.Provider(BackendLocal)
+ if err != nil {
+ t.Fatalf("Provider(%q) error = %v", BackendLocal, err)
+ }
+ if provider.Backend() != BackendLocal {
+ t.Fatalf("Provider(%q).Backend() = %q, want %q", BackendLocal, provider.Backend(), BackendLocal)
+ }
+}
+
+func TestRegistryProvidersReturnsSnapshot(t *testing.T) {
+ t.Parallel()
+
+ registry, err := NewRegistry(registryTestProvider{backend: BackendLocal})
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+
+ snapshot := registry.Providers()
+ if len(snapshot) != 1 {
+ t.Fatalf("Providers() length = %d, want 1", len(snapshot))
+ }
+ snapshot[BackendLocal] = nil
+
+ provider, err := registry.Provider(BackendLocal)
+ if err != nil {
+ t.Fatalf("Provider(%q) after snapshot mutation error = %v", BackendLocal, err)
+ }
+ if provider == nil {
+ t.Fatal("Provider() = nil after snapshot mutation, want registry to remain unchanged")
+ }
+
+ var nilRegistry *Registry
+ if got := nilRegistry.Providers(); len(got) != 0 {
+ t.Fatalf("nil Registry.Providers() length = %d, want 0", len(got))
+ }
+}
+
+type registryTestProvider struct {
+ backend Backend
+}
+
+func (p registryTestProvider) Backend() Backend {
+ return p.backend
+}
+
+func (p registryTestProvider) Prepare(context.Context, PrepareRequest) (Prepared, error) {
+ return Prepared{}, nil
+}
+
+func (p registryTestProvider) SyncToRuntime(context.Context, SessionState, SyncOptions) (SyncResult, error) {
+ return SyncResult{}, nil
+}
+
+func (p registryTestProvider) SyncFromRuntime(context.Context, SessionState, SyncOptions) (SyncResult, error) {
+ return SyncResult{}, nil
+}
+
+func (p registryTestProvider) Destroy(context.Context, SessionState) error {
+ return nil
+}
diff --git a/internal/environment/types.go b/internal/environment/types.go
new file mode 100644
index 000000000..16b0f42e2
--- /dev/null
+++ b/internal/environment/types.go
@@ -0,0 +1,303 @@
+// Package environment defines execution-environment contracts shared by
+// daemon-native providers, session orchestration, and ACP launch plumbing.
+package environment
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "time"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+)
+
+// Backend identifies the execution environment backend implementation.
+type Backend string
+
+const (
+ // BackendLocal runs agents as local daemon-host subprocesses.
+ BackendLocal Backend = "local"
+ // BackendDaytona runs agents inside Daytona sandboxes.
+ BackendDaytona Backend = "daytona"
+ // BackendE2B is reserved for a future E2B provider.
+ BackendE2B Backend = "e2b"
+)
+
+// ErrEnvironmentNotFound reports that a provider could not find a remote
+// environment matching daemon-owned identity labels.
+var ErrEnvironmentNotFound = errors.New("environment: remote environment not found")
+
+// Valid reports whether b is a known backend identifier.
+func (b Backend) Valid() bool {
+ switch b {
+ case BackendLocal, BackendDaytona, BackendE2B:
+ return true
+ default:
+ return false
+ }
+}
+
+// SyncMode controls workspace synchronization between local and runtime roots.
+type SyncMode string
+
+const (
+ // SyncModeNone disables automatic workspace synchronization.
+ SyncModeNone SyncMode = "none"
+ // SyncModeSessionBidirectional syncs local-to-runtime on start and runtime-to-local on stop.
+ SyncModeSessionBidirectional SyncMode = "session-bidirectional"
+ // SyncModeTurnBidirectional is reserved for future turn-boundary synchronization.
+ SyncModeTurnBidirectional SyncMode = "turn-bidirectional"
+)
+
+// Valid reports whether m is a known sync mode.
+func (m SyncMode) Valid() bool {
+ switch m {
+ case SyncModeNone, SyncModeSessionBidirectional, SyncModeTurnBidirectional:
+ return true
+ default:
+ return false
+ }
+}
+
+// PersistenceMode controls whether provider instances are reused or discarded.
+type PersistenceMode string
+
+const (
+ // PersistenceTransient destroys the runtime environment when the session stops.
+ PersistenceTransient PersistenceMode = "transient"
+ // PersistenceReuse keeps the runtime environment available for reuse.
+ PersistenceReuse PersistenceMode = "reuse"
+ // PersistenceArchive archives the runtime environment when possible.
+ PersistenceArchive PersistenceMode = "archive"
+)
+
+// Valid reports whether m is a known persistence mode.
+func (m PersistenceMode) Valid() bool {
+ switch m {
+ case PersistenceTransient, PersistenceReuse, PersistenceArchive:
+ return true
+ default:
+ return false
+ }
+}
+
+// DaytonaStartupSource identifies which Daytona startup input is authoritative.
+type DaytonaStartupSource string
+
+const (
+ // DaytonaStartupSourceImage starts a sandbox from an image.
+ DaytonaStartupSourceImage DaytonaStartupSource = "image"
+ // DaytonaStartupSourceSnapshot starts a sandbox from a pre-baked snapshot.
+ DaytonaStartupSourceSnapshot DaytonaStartupSource = "snapshot"
+)
+
+// NetworkPolicy is the resolved provider-neutral network intent.
+type NetworkPolicy struct {
+ AllowPublicIngress bool
+ AllowOutbound bool
+ AllowList []string
+ DenyList []string
+ Required bool
+}
+
+// DaytonaConfig is the resolved Daytona-specific provider policy.
+type DaytonaConfig struct {
+ APIURL string
+ Target string
+ Image string
+ Snapshot string
+ Class string
+ AutoStop string
+ AutoArchive string
+ StartupSource DaytonaStartupSource
+ StartupRef string
+}
+
+// Resolved is the workspace-selected environment profile after defaults and
+// backend policy have been applied.
+type Resolved struct {
+ Profile string
+ Backend Backend
+ SyncMode SyncMode
+ Persistence PersistenceMode
+ RuntimeRootDir string
+ DestroyOnStop bool
+ Env map[string]string
+ Network NetworkPolicy
+ Daytona *DaytonaConfig
+}
+
+// SessionState is the provider runtime state persisted for a session.
+type SessionState struct {
+ EnvironmentID string
+ Backend Backend
+ Profile string
+ State string
+ InstanceID string
+ RuntimeRootDir string
+ RuntimeAdditionalDirs []string
+ ProviderState json.RawMessage
+ SSHAccessExpiresAt *time.Time
+ PreparedAt time.Time
+}
+
+// PrepareRequest carries all daemon state needed to prepare an environment.
+type PrepareRequest struct {
+ SessionID string
+ WorkspaceID string
+ EnvironmentID string
+ InstanceID string
+ LocalRootDir string
+ LocalAdditionalDirs []string
+ Environment Resolved
+ AgentCommand string
+ AgentEnv []string
+ Permissions string
+ ResumeACPState string
+ ProviderState json.RawMessage
+}
+
+// FindEnvironmentRequest carries daemon identity for provider-side lookup of
+// a partially-created remote environment.
+type FindEnvironmentRequest struct {
+ SessionID string
+ WorkspaceID string
+ EnvironmentID string
+ LocalRootDir string
+ LocalAdditionalDirs []string
+ Environment Resolved
+ ProviderState json.RawMessage
+ Labels map[string]string
+}
+
+// Prepared is the result of preparing an execution environment for a session.
+type Prepared struct {
+ State SessionState
+ RuntimeRootDir string
+ RuntimeAdditionalDirs []string
+ Launcher Launcher
+ Launch LaunchSpec
+ ToolHost ToolHost
+}
+
+// SyncReason explains why a provider sync operation is running.
+type SyncReason string
+
+const (
+ // SyncReasonStart syncs before launching the agent.
+ SyncReasonStart SyncReason = "start"
+ // SyncReasonTurn is reserved for future turn-boundary synchronization.
+ SyncReasonTurn SyncReason = "turn"
+ // SyncReasonStop syncs during normal session stop.
+ SyncReasonStop SyncReason = "stop"
+ // SyncReasonCrash syncs during crash recovery.
+ SyncReasonCrash SyncReason = "crash"
+)
+
+// SyncDirection identifies the direction of a workspace synchronization.
+type SyncDirection string
+
+const (
+ // SyncDirectionToRuntime syncs local workspace files into the runtime.
+ SyncDirectionToRuntime SyncDirection = "to_runtime"
+ // SyncDirectionFromRuntime syncs runtime workspace files back to local storage.
+ SyncDirectionFromRuntime SyncDirection = "from_runtime"
+)
+
+// SyncOptions carries daemon decisions that affect one provider sync run.
+type SyncOptions struct {
+ Reason SyncReason
+ ExcludePatterns []string
+}
+
+// SyncResult reports provider-observed transfer statistics.
+type SyncResult struct {
+ FilesSynced int
+ BytesTransferred int64
+ Errors []string
+}
+
+// LaunchSpec describes the ACP-capable command to start inside an environment.
+type LaunchSpec struct {
+ Command string
+ Cwd string
+ AdditionalDirs []string
+ Env []string
+}
+
+// Provider manages the lifecycle of an execution environment.
+type Provider interface {
+ Backend() Backend
+ Prepare(ctx context.Context, req PrepareRequest) (Prepared, error)
+ SyncToRuntime(ctx context.Context, state SessionState, opts SyncOptions) (SyncResult, error)
+ SyncFromRuntime(ctx context.Context, state SessionState, opts SyncOptions) (SyncResult, error)
+ Destroy(ctx context.Context, state SessionState) error
+}
+
+// Finder is optionally implemented by remote providers that can discover
+// provider resources by daemon-owned identity labels.
+type Finder interface {
+ FindEnvironment(ctx context.Context, req FindEnvironmentRequest) (SessionState, error)
+}
+
+// Launcher starts an ACP-capable agent process inside an environment.
+type Launcher interface {
+ Launch(ctx context.Context, spec LaunchSpec) (Handle, error)
+}
+
+// Handle represents a running agent process.
+type Handle interface {
+ PID() int
+ Cwd() string
+ Stdin() io.WriteCloser
+ Stdout() io.ReadCloser
+ Stderr() string
+ Done() <-chan struct{}
+ Wait() error
+ Stop(ctx context.Context) error
+}
+
+// PermissionOperation identifies a ToolHost operation subject to policy.
+type PermissionOperation string
+
+const (
+ // PermissionOperationReadTextFile authorizes ACP text file reads.
+ PermissionOperationReadTextFile PermissionOperation = "fs/read_text_file"
+ // PermissionOperationWriteTextFile authorizes ACP text file writes.
+ PermissionOperationWriteTextFile PermissionOperation = "fs/write_text_file"
+ // PermissionOperationCreateTerminal authorizes terminal creation.
+ PermissionOperationCreateTerminal PermissionOperation = "terminal/create"
+ // PermissionOperationRequestToolGrant authorizes interactive permission requests.
+ PermissionOperationRequestToolGrant PermissionOperation = "session/request_permission"
+)
+
+// PermissionDecision is a daemon policy decision for an ACP permission request.
+type PermissionDecision string
+
+const (
+ // PermissionDecisionPending asks an operator or client to decide.
+ PermissionDecisionPending PermissionDecision = "pending"
+ // PermissionDecisionAllowOnce permits one operation.
+ PermissionDecisionAllowOnce PermissionDecision = "allow-once"
+ // PermissionDecisionAllowAlways permits this class of operation persistently.
+ PermissionDecisionAllowAlways PermissionDecision = "allow-always"
+ // PermissionDecisionRejectOnce rejects one operation.
+ PermissionDecisionRejectOnce PermissionDecision = "reject-once"
+ // PermissionDecisionRejectAlways rejects this class of operation persistently.
+ PermissionDecisionRejectAlways PermissionDecision = "reject-always"
+)
+
+// ToolHost abstracts ACP file, permission, and terminal operations for a runtime.
+type ToolHost interface {
+ ReadTextFile(ctx context.Context, path string) (string, error)
+ WriteTextFile(ctx context.Context, path string, content string) error
+ ResolvePath(path string) (string, error)
+ Authorize(op PermissionOperation) error
+ PermissionDecision(req acpsdk.RequestPermissionRequest) (PermissionDecision, bool)
+ CreateTerminal(ctx context.Context, req acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error)
+ KillTerminal(id string) error
+ TerminalOutput(id string) (string, error)
+ WaitForTerminalExit(ctx context.Context, id string) (int, error)
+ ReleaseTerminal(id string) error
+}
diff --git a/internal/environment/types_test.go b/internal/environment/types_test.go
new file mode 100644
index 000000000..b26a60c88
--- /dev/null
+++ b/internal/environment/types_test.go
@@ -0,0 +1,76 @@
+package environment
+
+import "testing"
+
+func TestBackendValid(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ backend Backend
+ want bool
+ }{
+ {backend: BackendLocal, want: true},
+ {backend: BackendDaytona, want: true},
+ {backend: BackendE2B, want: true},
+ {backend: Backend("docker"), want: false},
+ {backend: Backend(""), want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.backend), func(t *testing.T) {
+ t.Parallel()
+
+ if got := tt.backend.Valid(); got != tt.want {
+ t.Fatalf("Backend.Valid() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSyncModeValid(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ mode SyncMode
+ want bool
+ }{
+ {mode: SyncModeNone, want: true},
+ {mode: SyncModeSessionBidirectional, want: true},
+ {mode: SyncModeTurnBidirectional, want: true},
+ {mode: SyncMode("always"), want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.mode), func(t *testing.T) {
+ t.Parallel()
+
+ if got := tt.mode.Valid(); got != tt.want {
+ t.Fatalf("SyncMode.Valid() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPersistenceModeValid(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ mode PersistenceMode
+ want bool
+ }{
+ {mode: PersistenceTransient, want: true},
+ {mode: PersistenceReuse, want: true},
+ {mode: PersistenceArchive, want: true},
+ {mode: PersistenceMode("forever"), want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.mode), func(t *testing.T) {
+ t.Parallel()
+
+ if got := tt.mode.Valid(); got != tt.want {
+ t.Fatalf("PersistenceMode.Valid() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/extension/bridge_delivery_integration_test.go b/internal/extension/bridge_delivery_integration_test.go
index c526bb1b6..53f399e0b 100644
--- a/internal/extension/bridge_delivery_integration_test.go
+++ b/internal/extension/bridge_delivery_integration_test.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log/slog"
+ "os"
"path/filepath"
"slices"
"sync"
@@ -238,6 +239,9 @@ func newDeliveryIntegrationEnv(
}
workspaceRoot := filepath.Join(t.TempDir(), "workspace")
+ if err := os.MkdirAll(workspaceRoot, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll(%q) error = %v", workspaceRoot, err)
+ }
baseNow := time.Date(2026, 4, 11, 3, 0, 0, 0, time.UTC)
resolvedWorkspace := workspacepkg.ResolvedWorkspace{
Workspace: workspacepkg.Workspace{
@@ -319,6 +323,7 @@ func newDeliveryIntegrationEnv(
session.WithStore(func(ctx context.Context, sessionID string, path string) (session.EventRecorder, error) {
return storeSessionDB(ctx, sessionID, path)
}),
+ session.WithEnvironmentRegistry(mustLocalEnvironmentRegistry(t)),
session.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
session.WithNow(func() time.Time { return baseNow }),
session.WithSessionIDGenerator(sequentialSessionIDGenerator("sess")),
diff --git a/internal/extension/bundle_additional_test.go b/internal/extension/bundle_additional_test.go
new file mode 100644
index 000000000..0d76a765c
--- /dev/null
+++ b/internal/extension/bundle_additional_test.go
@@ -0,0 +1,217 @@
+package extensionpkg
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ automationpkg "github.com/pedronauck/agh/internal/automation"
+)
+
+func TestLoadBundleSpecsLoadsMixedFormatsAndSorts(t *testing.T) {
+ t.Parallel()
+
+ rootDir := t.TempDir()
+ bundlesDir := filepath.Join(rootDir, "bundles")
+ if err := os.MkdirAll(bundlesDir, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll() error = %v", err)
+ }
+
+ if err := os.WriteFile(filepath.Join(bundlesDir, "zeta.toml"), []byte(`
+name = " Zeta "
+description = " Team bundle "
+
+[[profiles]]
+name = " default "
+
+[profiles.channels]
+primary = " ops "
+
+[[profiles.channels.items]]
+name = " ops "
+description = " Operations "
+`), 0o644); err != nil {
+ t.Fatalf("os.WriteFile(zeta.toml) error = %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(bundlesDir, "alpha.json"), []byte(`{
+ "bundle": {
+ "name": " Alpha ",
+ "description": " Alerts bundle ",
+ "profiles": [{
+ "name": " default ",
+ "channels": {
+ "primary": " alerts ",
+ "items": [{
+ "name": " alerts ",
+ "description": " Alerts channel "
+ }]
+ }
+ }]
+ }
+ }`), 0o644); err != nil {
+ t.Fatalf("os.WriteFile(alpha.json) error = %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(bundlesDir, "ignore.txt"), []byte("skip"), 0o644); err != nil {
+ t.Fatalf("os.WriteFile(ignore.txt) error = %v", err)
+ }
+
+ bundles, err := LoadBundleSpecs(rootDir, &Manifest{
+ Name: "bundle-loader",
+ Resources: ResourcesConfig{
+ Bundles: []string{"bundles"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("LoadBundleSpecs() error = %v", err)
+ }
+ if len(bundles) != 2 {
+ t.Fatalf("len(bundles) = %d, want 2", len(bundles))
+ }
+
+ gotNames := []string{bundles[0].Name, bundles[1].Name}
+ if !slices.Equal(gotNames, []string{"Alpha", "Zeta"}) {
+ t.Fatalf("bundle names = %#v, want sorted trimmed names", gotNames)
+ }
+ if bundles[0].Profiles[0].Channels.Primary != "alerts" {
+ t.Fatalf("alpha primary channel = %q, want alerts", bundles[0].Profiles[0].Channels.Primary)
+ }
+ if bundles[1].Profiles[0].Channels.Items[0].Description != "Operations" {
+ t.Fatalf(
+ "zeta channel description = %q, want Operations",
+ bundles[1].Profiles[0].Channels.Items[0].Description,
+ )
+ }
+}
+
+func TestBundleDocumentToBundleSpecNormalizesValuesAndDefaults(t *testing.T) {
+ t.Parallel()
+
+ disabled := false
+ doc := bundleDocument{
+ Bundle: bundleRawSpec{
+ Name: " Marketing ",
+ Description: " Team bundle ",
+ Profiles: []bundleRawProfile{{
+ Name: " default ",
+ Description: " Primary profile ",
+ Channels: BundleChannelsConfig{
+ Primary: " ops ",
+ Items: []BundleChannel{{
+ Name: " ops ",
+ Description: " Operations ",
+ }},
+ },
+ Jobs: []bundleRawJob{{
+ Name: " daily-digest ",
+ AgentName: " planner ",
+ Prompt: " summarize incidents ",
+ Schedule: automationpkg.ScheduleSpec{
+ Mode: automationpkg.ScheduleModeEvery,
+ Interval: "1m",
+ },
+ Task: &automationpkg.JobTaskConfig{NetworkChannel: "ops"},
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ }, {
+ Name: " disabled-job ",
+ AgentName: " planner ",
+ Prompt: " summarize incidents ",
+ Enabled: &disabled,
+ Schedule: automationpkg.ScheduleSpec{
+ Mode: automationpkg.ScheduleModeEvery,
+ Interval: "5m",
+ },
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ }},
+ Triggers: []bundleRawTrigger{{
+ Name: " mention-alert ",
+ AgentName: " planner ",
+ Prompt: " triage this ",
+ Event: "message.created",
+ Filter: map[string]string{"team": "ops"},
+ Retry: automationpkg.DefaultRetryConfig(),
+ FireLimit: automationpkg.DefaultFireLimitConfig(),
+ EndpointSlug: " /alerts ",
+ }},
+ Bridges: []BundleBridgePreset{{
+ Name: " telegram-main ",
+ ExtensionName: " bundled.bridge ",
+ DisplayName: " Marketing Bridge ",
+ DeliveryDefaults: json.RawMessage(`{"mode":"safe"}`),
+ SecretSlots: []BundleBridgeSecretSlot{{
+ Name: " bot_token ",
+ Kind: " api_token ",
+ Description: " Bot token ",
+ }},
+ }},
+ }},
+ },
+ }
+
+ spec, err := doc.toBundleSpec()
+ if err != nil {
+ t.Fatalf("toBundleSpec() error = %v", err)
+ }
+ if spec.Name != "Marketing" {
+ t.Fatalf("spec.Name = %q, want Marketing", spec.Name)
+ }
+ if spec.Description != "Team bundle" {
+ t.Fatalf("spec.Description = %q, want Team bundle", spec.Description)
+ }
+
+ profile := spec.Profiles[0]
+ if profile.Name != "default" {
+ t.Fatalf("profile.Name = %q, want default", profile.Name)
+ }
+ if profile.Channels.Primary != "ops" {
+ t.Fatalf("profile.Channels.Primary = %q, want ops", profile.Channels.Primary)
+ }
+ if !profile.Jobs[0].Enabled {
+ t.Fatalf("jobs[0].Enabled = false, want true default")
+ }
+ if profile.Jobs[1].Enabled {
+ t.Fatalf("jobs[1].Enabled = true, want explicit false")
+ }
+ if !profile.Triggers[0].Enabled {
+ t.Fatalf("triggers[0].Enabled = false, want true default")
+ }
+ if profile.Triggers[0].EndpointSlug != "/alerts" {
+ t.Fatalf("triggers[0].EndpointSlug = %q, want /alerts", profile.Triggers[0].EndpointSlug)
+ }
+ if profile.Bridges[0].SecretSlots[0].Kind != "api_token" {
+ t.Fatalf("bridges[0].SecretSlots[0].Kind = %q, want api_token", profile.Bridges[0].SecretSlots[0].Kind)
+ }
+
+ profile.Jobs[0].Task.NetworkChannel = "changed"
+ if doc.Bundle.Profiles[0].Jobs[0].Task.NetworkChannel != "ops" {
+ t.Fatalf("raw job task mutated to %#v", doc.Bundle.Profiles[0].Jobs[0].Task)
+ }
+
+ profile.Triggers[0].Filter["team"] = "security"
+ if doc.Bundle.Profiles[0].Triggers[0].Filter["team"] != "ops" {
+ t.Fatalf("raw trigger filter mutated to %#v", doc.Bundle.Profiles[0].Triggers[0].Filter)
+ }
+
+ profile.Bridges[0].SecretSlots[0].Name = "changed"
+ if doc.Bundle.Profiles[0].Bridges[0].SecretSlots[0].Name != " bot_token " {
+ t.Fatalf("raw bridge secret slot mutated to %#v", doc.Bundle.Profiles[0].Bridges[0].SecretSlots)
+ }
+}
+
+func TestBundleDocumentToBundleSpecRejectsConflictingProfileDeclarations(t *testing.T) {
+ t.Parallel()
+
+ _, err := (bundleDocument{
+ Profiles: []bundleRawProfile{{Name: "root"}},
+ Bundle: bundleRawSpec{
+ Profiles: []bundleRawProfile{{Name: "bundle"}},
+ },
+ }).toBundleSpec()
+ if !errors.Is(err, ErrBundleInvalid) {
+ t.Fatalf("toBundleSpec() error = %v, want ErrBundleInvalid", err)
+ }
+}
diff --git a/internal/extension/capability.go b/internal/extension/capability.go
index e47d59177..106ae76ec 100644
--- a/internal/extension/capability.go
+++ b/internal/extension/capability.go
@@ -6,6 +6,10 @@ import (
"slices"
"strings"
"sync"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/extension/surfaces"
+ "github.com/pedronauck/agh/internal/resources"
)
const (
@@ -44,6 +48,9 @@ var (
"tasks/runs/complete": "task.write",
"tasks/runs/fail": "task.write",
"tasks/runs/cancel": "task.write",
+ "resources/list": "resource.read",
+ "resources/get": "resource.read",
+ "resources/snapshot": "resource.write",
"bridges/instances/list": "bridge.read",
"bridges/instances/get": "bridge.read",
"bridges/instances/report_state": "bridge.write",
@@ -53,6 +60,9 @@ var (
"memory/store": "memory.write",
"observe/events": "observe.read",
"observe/health": "observe.read",
+ "environment/list": "",
+ "environment/info": "",
+ "environment/exec": "environment.exec",
"sessions/create": "session.write",
"sessions/events": "session.read",
"sessions/list": "session.read",
@@ -142,39 +152,54 @@ func (e *ErrCapabilityDenied) Code() int {
// CapabilityChecker tracks effective grants per extension and evaluates
// capability checks for hook dispatch and Host API calls.
type CapabilityChecker struct {
- mu sync.RWMutex
- grants map[string]capabilityGrant
+ mu sync.RWMutex
+ grants map[string]capabilityGrant
+ resourcePolicy aghconfig.ExtensionsResourcesConfig
}
type capabilityGrant struct {
- source ExtensionSource
- actions []string
- security []string
+ source ExtensionSource
+ actions []string
+ security []string
+ resourceKinds []resources.ResourceKind
+ resourceScopes []resources.ResourceScopeKind
+}
+
+// EffectiveGrant is the daemon-derived grant snapshot for one extension session.
+type EffectiveGrant struct {
+ Actions []string
+ Security []string
+ ResourceKinds []resources.ResourceKind
+ ResourceScopes []resources.ResourceScopeKind
}
// Register records one extension's effective grants by applying the source-tier
// ceiling before intersecting it with the manifest requests.
func (c *CapabilityChecker) Register(extName string, source ExtensionSource, manifest *Manifest) {
- if c == nil {
+ if _, err := c.RegisterForSession(extName, source, manifest, resources.ResourceScopeKindGlobal); err != nil {
return
}
+}
- name := strings.TrimSpace(extName)
- if name == "" {
- return
+// RegisterForSession records one extension's effective grants for the supplied session scope ceiling.
+func (c *CapabilityChecker) RegisterForSession(
+ extName string,
+ source ExtensionSource,
+ manifest *Manifest,
+ sessionMaxScope resources.ResourceScopeKind,
+) (EffectiveGrant, error) {
+ if c == nil {
+ return EffectiveGrant{}, nil
}
- var requestedActions []string
- var requestedSecurity []string
- if manifest != nil {
- requestedActions = normalizeUniqueStrings(manifest.Actions.Requires)
- requestedSecurity = normalizeUniqueStrings(manifest.Security.Capabilities)
+ name := strings.TrimSpace(extName)
+ if name == "" {
+ return EffectiveGrant{}, nil
}
- grant := capabilityGrant{
- source: source,
- actions: effectiveActionGrants(source, requestedActions),
- security: effectiveSecurityGrants(source, requestedSecurity),
+ grant, err := c.resolve(source, manifest, sessionMaxScope)
+ if err != nil {
+ return EffectiveGrant{}, err
}
c.mu.Lock()
@@ -183,6 +208,40 @@ func (c *CapabilityChecker) Register(extName string, source ExtensionSource, man
c.grants = make(map[string]capabilityGrant)
}
c.grants[name] = grant
+ return grant.snapshot(), nil
+}
+
+// Resolve computes one daemon-derived grant snapshot without storing it.
+func (c *CapabilityChecker) Resolve(
+ source ExtensionSource,
+ manifest *Manifest,
+ sessionMaxScope resources.ResourceScopeKind,
+) (EffectiveGrant, error) {
+ if c == nil {
+ return EffectiveGrant{}, nil
+ }
+
+ grant, err := c.resolve(source, manifest, sessionMaxScope)
+ if err != nil {
+ return EffectiveGrant{}, err
+ }
+ return grant.snapshot(), nil
+}
+
+// Grant returns the stored effective grant snapshot for one extension.
+func (c *CapabilityChecker) Grant(extName string) EffectiveGrant {
+ return c.lookup(extName).snapshot()
+}
+
+// SetResourcePolicy installs the operator-configured extension resource policy.
+func (c *CapabilityChecker) SetResourcePolicy(policy aghconfig.ExtensionsResourcesConfig) {
+ if c == nil {
+ return
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.resourcePolicy = cloneResourcePolicy(policy)
}
// Unregister removes any effective grants tracked for one extension.
@@ -235,6 +294,9 @@ func (c *CapabilityChecker) CheckHostAPI(extName, method string) error {
if !slices.Contains(grant.actions, method) {
return newCapabilityDeniedError(method, []string{method}, grant.actions)
}
+ if strings.TrimSpace(requiredSecurity) == "" {
+ return nil
+ }
if !capabilityGranted(grant.security, requiredSecurity) {
return newCapabilityDeniedError(method, []string{requiredSecurity}, grant.security)
}
@@ -254,6 +316,59 @@ func (c *CapabilityChecker) lookup(extName string) capabilityGrant {
return c.grants[strings.TrimSpace(extName)]
}
+func (c *CapabilityChecker) resolve(
+ source ExtensionSource,
+ manifest *Manifest,
+ sessionMaxScope resources.ResourceScopeKind,
+) (capabilityGrant, error) {
+ c.mu.RLock()
+ policy := cloneResourcePolicy(c.resourcePolicy)
+ c.mu.RUnlock()
+
+ var requestedActions []string
+ var requestedSecurity []string
+ var requestedResources surfaces.GrantRequest
+ var err error
+ if manifest != nil {
+ requestedActions = normalizeUniqueStrings(manifest.Actions.Requires)
+ requestedSecurity = normalizeUniqueStrings(manifest.Security.Capabilities)
+ requestedResources, err = surfaces.ResolveManifestRequest(
+ manifest.Resources.Publish.Families,
+ manifest.Resources.Publish.MaxScope,
+ )
+ if err != nil {
+ return capabilityGrant{}, err
+ }
+ }
+
+ resourceKinds, resourceScopes, err := effectiveResourceGrants(
+ source,
+ policy,
+ requestedResources,
+ sessionMaxScope,
+ )
+ if err != nil {
+ return capabilityGrant{}, err
+ }
+
+ return capabilityGrant{
+ source: source,
+ actions: effectiveActionGrants(source, requestedActions),
+ security: effectiveSecurityGrants(source, requestedSecurity),
+ resourceKinds: resourceKinds,
+ resourceScopes: resourceScopes,
+ }, nil
+}
+
+func (g capabilityGrant) snapshot() EffectiveGrant {
+ return EffectiveGrant{
+ Actions: slices.Clone(g.actions),
+ Security: slices.Clone(g.security),
+ ResourceKinds: slices.Clone(g.resourceKinds),
+ ResourceScopes: slices.Clone(g.resourceScopes),
+ }
+}
+
func newCapabilityDeniedError(method string, required []string, granted []string) error {
return &ErrCapabilityDenied{
Data: CapabilityDeniedData{
@@ -393,6 +508,7 @@ type sourceTierPolicy struct {
allowAllSecurity bool
allowedActions []string
allowedSecurity []string
+ maxResourceScope resources.ResourceScopeKind
}
func sourcePolicy(source ExtensionSource) sourceTierPolicy {
@@ -401,11 +517,13 @@ func sourcePolicy(source ExtensionSource) sourceTierPolicy {
return sourceTierPolicy{
allowAllActions: true,
allowAllSecurity: true,
+ maxResourceScope: sourceTierMaxScope(source),
}
case SourceMarketplace:
return sourceTierPolicy{
- allowedActions: marketplaceActionCeiling(),
- allowedSecurity: slices.Clone(marketplaceSecurityCeiling),
+ allowedActions: marketplaceActionCeiling(),
+ allowedSecurity: slices.Clone(marketplaceSecurityCeiling),
+ maxResourceScope: sourceTierMaxScope(source),
}
default:
return sourceTierPolicy{}
@@ -422,3 +540,176 @@ func marketplaceActionCeiling() []string {
slices.Sort(actions)
return actions
}
+
+func effectiveResourceGrants(
+ source ExtensionSource,
+ operatorPolicy aghconfig.ExtensionsResourcesConfig,
+ requested surfaces.GrantRequest,
+ sessionMaxScope resources.ResourceScopeKind,
+) ([]resources.ResourceKind, []resources.ResourceScopeKind, error) {
+ if len(requested.Kinds) == 0 {
+ return nil, nil, nil
+ }
+
+ grantedKinds := slices.Clone(requested.Kinds)
+ if len(operatorPolicy.AllowedKinds) > 0 {
+ allowedKinds, err := surfaces.NormalizeAllowedKinds(operatorPolicy.AllowedKinds)
+ if err != nil {
+ return nil, nil, err
+ }
+ grantedKinds = intersectKinds(grantedKinds, allowedKinds)
+ }
+ if len(grantedKinds) == 0 {
+ return nil, nil, nil
+ }
+
+ finalMaxScope, err := narrowScopeCeiling(
+ requested.MaxScope,
+ sourceTierMaxScope(source),
+ operatorPolicy.MaxScope,
+ sessionMaxScope,
+ )
+ if err != nil {
+ return nil, nil, err
+ }
+ grantedScopes := intersectScopes(requested.Scopes, scopesThrough(finalMaxScope))
+ if len(grantedScopes) == 0 {
+ return nil, nil, nil
+ }
+ return grantedKinds, grantedScopes, nil
+}
+
+func sourceTierMaxScope(source ExtensionSource) resources.ResourceScopeKind {
+ switch source {
+ case SourceWorkspace, SourceMarketplace:
+ return resources.ResourceScopeKindWorkspace
+ case SourceBundled, SourceUser:
+ return resources.ResourceScopeKindGlobal
+ default:
+ return ""
+ }
+}
+
+func narrowScopeCeiling(
+ requested resources.ResourceScopeKind,
+ sourceTier resources.ResourceScopeKind,
+ operator resources.ResourceScopeKind,
+ session resources.ResourceScopeKind,
+) (resources.ResourceScopeKind, error) {
+ candidates := []resources.ResourceScopeKind{
+ requested.Normalize(),
+ sourceTier.Normalize(),
+ operator.Normalize(),
+ session.Normalize(),
+ }
+ result := resources.ResourceScopeKindGlobal
+ seen := false
+ for _, candidate := range candidates {
+ if candidate == "" {
+ continue
+ }
+ if err := candidate.Validate("resource scope"); err != nil {
+ return "", err
+ }
+ if !seen || scopeRank(candidate) < scopeRank(result) {
+ result = candidate
+ seen = true
+ }
+ }
+ if !seen {
+ return "", nil
+ }
+ return result, nil
+}
+
+func scopeRank(scope resources.ResourceScopeKind) int {
+ switch scope.Normalize() {
+ case resources.ResourceScopeKindWorkspace:
+ return 0
+ case resources.ResourceScopeKindGlobal:
+ return 1
+ default:
+ return 2
+ }
+}
+
+func scopesThrough(maxScope resources.ResourceScopeKind) []resources.ResourceScopeKind {
+ switch maxScope.Normalize() {
+ case resources.ResourceScopeKindGlobal:
+ return []resources.ResourceScopeKind{
+ resources.ResourceScopeKindGlobal,
+ resources.ResourceScopeKindWorkspace,
+ }
+ case resources.ResourceScopeKindWorkspace:
+ return []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}
+ default:
+ return nil
+ }
+}
+
+func intersectKinds(
+ left []resources.ResourceKind,
+ right []resources.ResourceKind,
+) []resources.ResourceKind {
+ if len(left) == 0 || len(right) == 0 {
+ return nil
+ }
+ index := make(map[resources.ResourceKind]struct{}, len(right))
+ for _, kind := range right {
+ index[kind.Normalize()] = struct{}{}
+ }
+ var kinds []resources.ResourceKind
+ for _, kind := range left {
+ normalized := kind.Normalize()
+ if _, ok := index[normalized]; ok {
+ kinds = append(kinds, normalized)
+ }
+ }
+ if len(kinds) == 0 {
+ return nil
+ }
+ slices.Sort(kinds)
+ return kinds
+}
+
+func intersectScopes(
+ left []resources.ResourceScopeKind,
+ right []resources.ResourceScopeKind,
+) []resources.ResourceScopeKind {
+ if len(left) == 0 || len(right) == 0 {
+ return nil
+ }
+ index := make(map[resources.ResourceScopeKind]struct{}, len(right))
+ for _, scope := range right {
+ index[scope.Normalize()] = struct{}{}
+ }
+ var scopes []resources.ResourceScopeKind
+ for _, scope := range left {
+ normalized := scope.Normalize()
+ if _, ok := index[normalized]; ok {
+ scopes = append(scopes, normalized)
+ }
+ }
+ if len(scopes) == 0 {
+ return nil
+ }
+ slices.Sort(scopes)
+ return scopes
+}
+
+func cloneResourcePolicy(policy aghconfig.ExtensionsResourcesConfig) aghconfig.ExtensionsResourcesConfig {
+ return aghconfig.ExtensionsResourcesConfig{
+ AllowedKinds: append([]resources.ResourceKind(nil), policy.AllowedKinds...),
+ MaxScope: policy.MaxScope,
+ SnapshotRateLimit: aghconfig.ExtensionsResourceRateLimitConfig{
+ Requests: policy.SnapshotRateLimit.Requests,
+ Window: policy.SnapshotRateLimit.Window,
+ Queue: policy.SnapshotRateLimit.Queue,
+ },
+ OperatorWriteRateLimit: aghconfig.ExtensionsResourceRateLimitConfig{
+ Requests: policy.OperatorWriteRateLimit.Requests,
+ Window: policy.OperatorWriteRateLimit.Window,
+ Queue: policy.OperatorWriteRateLimit.Queue,
+ },
+ }
+}
diff --git a/internal/extension/capability_test.go b/internal/extension/capability_test.go
index 6083f3ce2..feedc68cf 100644
--- a/internal/extension/capability_test.go
+++ b/internal/extension/capability_test.go
@@ -4,6 +4,9 @@ import (
"errors"
"slices"
"testing"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/resources"
)
func TestCapabilityCheckerCheckShouldAllowGrantedCapability(t *testing.T) {
@@ -84,6 +87,31 @@ func TestCapabilityCheckerCheckHostAPIShouldEnforceDualGates(t *testing.T) {
security: []string{"bridge.read"},
method: "bridges/instances/get",
},
+ {
+ name: "allows environment list with action grant only",
+ actions: []string{"environment/list"},
+ method: "environment/list",
+ },
+ {
+ name: "allows environment info with action grant only",
+ actions: []string{"environment/info"},
+ method: "environment/info",
+ },
+ {
+ name: "allows environment exec with action and exec capability",
+ actions: []string{"environment/exec"},
+ security: []string{"environment.exec"},
+ method: "environment/exec",
+ },
+ {
+ name: "rejects environment exec without exec capability",
+ actions: []string{"environment/exec"},
+ security: []string{"session.read"},
+ method: "environment/exec",
+ wantRequired: []string{"environment.exec"},
+ wantGranted: []string{"session.read"},
+ wantErr: true,
+ },
{
name: "ShouldAllowBridgeStateReportWithWriteGrant",
actions: []string{"bridges/instances/report_state"},
@@ -372,6 +400,221 @@ func TestCapabilityCheckerCheckShouldHonorFamilyWildcardGrant(t *testing.T) {
}
}
+func TestCapabilityCheckerResolveShouldApplyOperatorResourcePolicy(t *testing.T) {
+ t.Parallel()
+
+ checker := &CapabilityChecker{}
+ checker.SetResourcePolicy(aghconfig.ExtensionsResourcesConfig{
+ AllowedKinds: []resources.ResourceKind{resources.ResourceKind("tool")},
+ MaxScope: resources.ResourceScopeKindWorkspace,
+ })
+
+ grant, err := checker.Resolve(SourceUser, &Manifest{
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: []string{"tools", "mcp_servers"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ },
+ },
+ }, resources.ResourceScopeKindGlobal)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if !slices.Equal(grant.ResourceKinds, []resources.ResourceKind{resources.ResourceKind("tool")}) {
+ t.Fatalf("Resolve().ResourceKinds = %#v, want [tool]", grant.ResourceKinds)
+ }
+ if !slices.Equal(grant.ResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("Resolve().ResourceScopes = %#v, want [workspace]", grant.ResourceScopes)
+ }
+}
+
+func TestCapabilityCheckerResolveShouldApplySourceTierScopeCeiling(t *testing.T) {
+ t.Parallel()
+
+ checker := &CapabilityChecker{}
+ grant, err := checker.Resolve(SourceWorkspace, &Manifest{
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: []string{"tools"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ },
+ },
+ }, resources.ResourceScopeKindGlobal)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if !slices.Equal(grant.ResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("Resolve().ResourceScopes = %#v, want [workspace]", grant.ResourceScopes)
+ }
+}
+
+func TestCapabilityCheckerResolveShouldApplySessionModeScopeNarrowing(t *testing.T) {
+ t.Parallel()
+
+ checker := &CapabilityChecker{}
+ grant, err := checker.Resolve(SourceUser, &Manifest{
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: []string{"tools"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ },
+ },
+ }, resources.ResourceScopeKindWorkspace)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if !slices.Equal(grant.ResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("Resolve().ResourceScopes = %#v, want [workspace]", grant.ResourceScopes)
+ }
+}
+
+func TestCapabilityCheckerRegisterForSessionStoresGrantSnapshot(t *testing.T) {
+ t.Parallel()
+
+ checker := &CapabilityChecker{}
+ grant, err := checker.RegisterForSession("ext", SourceUser, &Manifest{
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: []string{"tools"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ },
+ },
+ }, resources.ResourceScopeKindWorkspace)
+ if err != nil {
+ t.Fatalf("RegisterForSession() error = %v", err)
+ }
+ if !slices.Equal(grant.ResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("RegisterForSession().ResourceScopes = %#v, want [workspace]", grant.ResourceScopes)
+ }
+
+ stored := checker.Grant("ext")
+ if !slices.Equal(stored.ResourceKinds, []resources.ResourceKind{resources.ResourceKind("tool")}) {
+ t.Fatalf("Grant().ResourceKinds = %#v, want [tool]", stored.ResourceKinds)
+ }
+
+ grant.ResourceKinds[0] = resources.ResourceKind("mutated")
+ if got := checker.Grant("ext").ResourceKinds[0]; got != resources.ResourceKind("tool") {
+ t.Fatalf("Grant() leaked caller mutation, got %q", got)
+ }
+}
+
+func TestCapabilityCheckerRegisterForSessionRejectsInvalidManifestResourceRequest(t *testing.T) {
+ t.Parallel()
+
+ checker := &CapabilityChecker{}
+ _, err := checker.RegisterForSession("ext", SourceUser, &Manifest{
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: []string{"bridge_instances"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ },
+ },
+ }, resources.ResourceScopeKindGlobal)
+ if err == nil {
+ t.Fatal("RegisterForSession() error = nil, want invalid manifest request")
+ }
+}
+
+func TestCapabilityCheckerNilResolveReturnsEmptyGrant(t *testing.T) {
+ t.Parallel()
+
+ var checker *CapabilityChecker
+ grant, err := checker.Resolve(SourceUser, nil, resources.ResourceScopeKindGlobal)
+ if err != nil {
+ t.Fatalf("Resolve(nil) error = %v, want nil", err)
+ }
+ if len(grant.Actions) != 0 ||
+ len(grant.Security) != 0 ||
+ len(grant.ResourceKinds) != 0 ||
+ len(grant.ResourceScopes) != 0 {
+ t.Fatalf("Resolve(nil) = %#v, want zero value", grant)
+ }
+}
+
+func TestSourceTierResourceHelpers(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ source ExtensionSource
+ wantScope resources.ResourceScopeKind
+ }{
+ {source: SourceBundled, wantScope: resources.ResourceScopeKindGlobal},
+ {source: SourceUser, wantScope: resources.ResourceScopeKindGlobal},
+ {source: SourceWorkspace, wantScope: resources.ResourceScopeKindWorkspace},
+ {source: SourceMarketplace, wantScope: resources.ResourceScopeKindWorkspace},
+ {source: ExtensionSource(99), wantScope: ""},
+ }
+
+ for _, tt := range tests {
+ if got := sourceTierMaxScope(tt.source); got != tt.wantScope {
+ t.Fatalf("sourceTierMaxScope(%v) = %q, want %q", tt.source, got, tt.wantScope)
+ }
+ }
+ if !slices.Equal(scopesThrough(resources.ResourceScopeKindGlobal), []resources.ResourceScopeKind{
+ resources.ResourceScopeKindGlobal,
+ resources.ResourceScopeKindWorkspace,
+ }) {
+ t.Fatalf("scopesThrough(global) = %#v, want global+workspace", scopesThrough(resources.ResourceScopeKindGlobal))
+ }
+ if !slices.Equal(scopesThrough(resources.ResourceScopeKindWorkspace), []resources.ResourceScopeKind{
+ resources.ResourceScopeKindWorkspace,
+ }) {
+ t.Fatalf(
+ "scopesThrough(workspace) = %#v, want [workspace]",
+ scopesThrough(resources.ResourceScopeKindWorkspace),
+ )
+ }
+ if got, want := scopeRank(resources.ResourceScopeKindWorkspace), 0; got != want {
+ t.Fatalf("scopeRank(workspace) = %d, want %d", got, want)
+ }
+ if got, want := scopeRank(resources.ResourceScopeKindGlobal), 1; got != want {
+ t.Fatalf("scopeRank(global) = %d, want %d", got, want)
+ }
+ if got, want := scopeRank(resources.ResourceScopeKind("")), 2; got != want {
+ t.Fatalf("scopeRank(unknown) = %d, want %d", got, want)
+ }
+ if got := scopesThrough(resources.ResourceScopeKind("invalid")); got != nil {
+ t.Fatalf("scopesThrough(invalid) = %#v, want nil", got)
+ }
+}
+
+func TestCapabilityHelperPoliciesAndCeilings(t *testing.T) {
+ t.Parallel()
+
+ if !ceilingAllowsRequestedGrant([]string{"network.*"}, "network.http") {
+ t.Fatalf("ceilingAllowsRequestedGrant() = false, want true for wildcard superset")
+ }
+ if ceilingAllowsRequestedGrant([]string{"network.http"}, "network.*") {
+ t.Fatalf("ceilingAllowsRequestedGrant() = true, want false when request exceeds ceiling")
+ }
+
+ marketplace := sourcePolicy(SourceMarketplace)
+ if marketplace.allowAllActions || marketplace.allowAllSecurity {
+ t.Fatalf("marketplace policy = %#v, want narrowed actions and security", marketplace)
+ }
+ if marketplace.maxResourceScope != resources.ResourceScopeKindWorkspace {
+ t.Fatalf("marketplace maxResourceScope = %q, want workspace", marketplace.maxResourceScope)
+ }
+ if len(marketplace.allowedActions) == 0 || len(marketplace.allowedSecurity) == 0 {
+ t.Fatalf("marketplace policy = %#v, want populated ceilings", marketplace)
+ }
+
+ bundled := sourcePolicy(SourceBundled)
+ if !bundled.allowAllActions || !bundled.allowAllSecurity {
+ t.Fatalf("bundled policy = %#v, want full action and security grants", bundled)
+ }
+ if bundled.maxResourceScope != resources.ResourceScopeKindGlobal {
+ t.Fatalf("bundled maxResourceScope = %q, want global", bundled.maxResourceScope)
+ }
+
+ if got, err := narrowScopeCeiling("", "", "", ""); err != nil || got != "" {
+ t.Fatalf("narrowScopeCeiling(empty) = (%q, %v), want empty nil", got, err)
+ }
+ if _, err := narrowScopeCeiling(resources.ResourceScopeKind("invalid"), "", "", ""); err == nil {
+ t.Fatalf("narrowScopeCeiling(invalid) error = nil, want validation error")
+ }
+}
+
func newTestCapabilityChecker(
extName string,
source ExtensionSource,
diff --git a/internal/extension/contract/host_api.go b/internal/extension/contract/host_api.go
index 4d896a02e..826fffe8a 100644
--- a/internal/extension/contract/host_api.go
+++ b/internal/extension/contract/host_api.go
@@ -1,6 +1,7 @@
package contract
import (
+ "encoding/json"
"time"
apicontract "github.com/pedronauck/agh/internal/api/contract"
@@ -9,6 +10,7 @@ import (
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
"github.com/pedronauck/agh/internal/memory"
observepkg "github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/store"
)
@@ -23,6 +25,9 @@ const (
HostAPIMethodSessionsStop = extensionprotocol.HostAPIMethodSessionsStop
HostAPIMethodSessionsStatus = extensionprotocol.HostAPIMethodSessionsStatus
HostAPIMethodSessionsEvents = extensionprotocol.HostAPIMethodSessionsEvents
+ HostAPIMethodEnvironmentList = extensionprotocol.HostAPIMethodEnvironmentList
+ HostAPIMethodEnvironmentInfo = extensionprotocol.HostAPIMethodEnvironmentInfo
+ HostAPIMethodEnvironmentExec = extensionprotocol.HostAPIMethodEnvironmentExec
HostAPIMethodMemoryRecall = extensionprotocol.HostAPIMethodMemoryRecall
HostAPIMethodMemoryStore = extensionprotocol.HostAPIMethodMemoryStore
HostAPIMethodMemoryForget = extensionprotocol.HostAPIMethodMemoryForget
@@ -57,6 +62,9 @@ const (
HostAPIMethodTasksRunsComplete = extensionprotocol.HostAPIMethodTasksRunsComplete
HostAPIMethodTasksRunsFail = extensionprotocol.HostAPIMethodTasksRunsFail
HostAPIMethodTasksRunsCancel = extensionprotocol.HostAPIMethodTasksRunsCancel
+ HostAPIMethodResourcesList = extensionprotocol.HostAPIMethodResourcesList
+ HostAPIMethodResourcesGet = extensionprotocol.HostAPIMethodResourcesGet
+ HostAPIMethodResourcesSnapshot = extensionprotocol.HostAPIMethodResourcesSnapshot
HostAPIMethodBridgesInstancesList = extensionprotocol.HostAPIMethodBridgesInstancesList
HostAPIMethodBridgesMessagesIngest = extensionprotocol.HostAPIMethodBridgesMessagesIngest
HostAPIMethodBridgesInstancesGet = extensionprotocol.HostAPIMethodBridgesInstancesGet
@@ -114,6 +122,23 @@ type SessionEventsParams struct {
Since time.Time `json:"since"`
}
+// EnvironmentListParams filters active environments.
+type EnvironmentListParams struct {
+ Workspace string `json:"workspace,omitempty"`
+}
+
+// EnvironmentInfoParams identifies one session environment.
+type EnvironmentInfoParams struct {
+ SessionID string `json:"session_id"`
+}
+
+// EnvironmentExecParams executes one command inside a session environment.
+type EnvironmentExecParams struct {
+ SessionID string `json:"session_id"`
+ Command string `json:"command"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
// MemoryStoreParams persists one memory document.
type MemoryStoreParams struct {
Key string `json:"key"`
@@ -297,6 +322,33 @@ type TaskRunCancelParams struct {
apicontract.CancelTaskRunRequest
}
+// ResourcesListParams filters same-source resource visibility for one extension actor.
+type ResourcesListParams struct {
+ Kind resources.ResourceKind `json:"kind,omitempty"`
+ Scope *resources.ResourceScope `json:"scope,omitempty"`
+ Limit int `json:"limit,omitempty"`
+}
+
+// ResourceGetParams identifies one canonical resource record by kind and id.
+type ResourceGetParams struct {
+ Kind resources.ResourceKind `json:"kind"`
+ ID string `json:"id"`
+}
+
+// ResourceSnapshotRecord carries one snapshot-authored resource definition.
+type ResourceSnapshotRecord struct {
+ Kind resources.ResourceKind `json:"kind"`
+ ID string `json:"id"`
+ Scope resources.ResourceScope `json:"scope"`
+ Spec json.RawMessage `json:"spec"`
+}
+
+// ResourcesSnapshotParams replaces one extension source snapshot.
+type ResourcesSnapshotParams struct {
+ SourceVersion int64 `json:"source_version"`
+ Records []ResourceSnapshotRecord `json:"records"`
+}
+
// BridgesMessagesIngestParams carries one normalized inbound bridge message.
type BridgesMessagesIngestParams = bridgepkg.InboundMessageEnvelope
@@ -355,6 +407,41 @@ type SessionPromptResult struct {
TurnID string `json:"turn_id"`
}
+// EnvironmentSummary is one active environment in the host-visible list response.
+type EnvironmentSummary struct {
+ SessionID string `json:"session_id"`
+ EnvironmentID string `json:"environment_id"`
+ Backend string `json:"backend"`
+ Profile string `json:"profile,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ State string `json:"state"`
+ SyncState string `json:"sync_state,omitempty"`
+}
+
+// EnvironmentListResult returns active environment instances.
+type EnvironmentListResult struct {
+ Environments []EnvironmentSummary `json:"environments"`
+}
+
+// EnvironmentInfoResult returns detailed environment state for a session.
+type EnvironmentInfoResult struct {
+ EnvironmentID string `json:"environment_id"`
+ Backend string `json:"backend"`
+ Profile string `json:"profile"`
+ InstanceID string `json:"instance_id"`
+ RuntimeRoot string `json:"runtime_root"`
+ SyncState string `json:"sync_state"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSyncError string `json:"last_sync_error"`
+}
+
+// EnvironmentExecResult returns command execution output.
+type EnvironmentExecResult struct {
+ ExitCode int `json:"exit_code"`
+ Stdout string `json:"stdout,omitempty"`
+ Stderr string `json:"stderr,omitempty"`
+}
+
// MemoryRecallEntry is one scored memory lookup hit.
type MemoryRecallEntry struct {
Key string `json:"key"`
@@ -372,6 +459,19 @@ type SkillSummary struct {
// ObserveHealth is the host-visible daemon health payload.
type ObserveHealth = observepkg.Health
+// ResourceRecord is the generic Host API desired-state shape exposed to extensions.
+type ResourceRecord struct {
+ Kind resources.ResourceKind `json:"kind"`
+ ID string `json:"id"`
+ Version int64 `json:"version"`
+ Scope resources.ResourceScope `json:"scope"`
+ Owner resources.ResourceOwner `json:"owner"`
+ Source resources.ResourceSource `json:"source"`
+ Spec json.RawMessage `json:"spec"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
// BridgesMessagesIngestResult reports the resolved session association for one inbound message.
type BridgesMessagesIngestResult struct {
SessionID string `json:"session_id"`
@@ -411,6 +511,22 @@ var hostAPIMethodSpecs = []HostAPIMethodSpec{
Params: NamedType{Name: "SessionEventsParams", Value: SessionEventsParams{}},
Result: NamedType{Name: "SessionEvent", Value: []SessionEvent{}},
},
+ {
+ Method: HostAPIMethodEnvironmentList,
+ Params: NamedType{Name: "EnvironmentListParams", Value: EnvironmentListParams{}},
+ Result: NamedType{Name: "EnvironmentListResult", Value: EnvironmentListResult{}},
+ OptionalParams: true,
+ },
+ {
+ Method: HostAPIMethodEnvironmentInfo,
+ Params: NamedType{Name: "EnvironmentInfoParams", Value: EnvironmentInfoParams{}},
+ Result: NamedType{Name: "EnvironmentInfoResult", Value: EnvironmentInfoResult{}},
+ },
+ {
+ Method: HostAPIMethodEnvironmentExec,
+ Params: NamedType{Name: "EnvironmentExecParams", Value: EnvironmentExecParams{}},
+ Result: NamedType{Name: "EnvironmentExecResult", Value: EnvironmentExecResult{}},
+ },
{
Method: HostAPIMethodMemoryRecall,
Params: NamedType{Name: "MemoryRecallParams", Value: MemoryRecallParams{}},
@@ -588,6 +704,22 @@ var hostAPIMethodSpecs = []HostAPIMethodSpec{
Params: NamedType{Name: "TaskRunCancelParams", Value: TaskRunCancelParams{}},
Result: NamedType{Name: "TaskRun", Value: apicontract.TaskRunPayload{}},
},
+ {
+ Method: HostAPIMethodResourcesList,
+ Params: NamedType{Name: "ResourcesListParams", Value: ResourcesListParams{}},
+ Result: NamedType{Name: "ResourceRecord", Value: []ResourceRecord{}},
+ OptionalParams: true,
+ },
+ {
+ Method: HostAPIMethodResourcesGet,
+ Params: NamedType{Name: "ResourceGetParams", Value: ResourceGetParams{}},
+ Result: NamedType{Name: "ResourceRecord", Value: ResourceRecord{}},
+ },
+ {
+ Method: HostAPIMethodResourcesSnapshot,
+ Params: NamedType{Name: "ResourcesSnapshotParams", Value: ResourcesSnapshotParams{}},
+ Result: NamedType{Name: "EmptyResult", Value: EmptyResult{}},
+ },
{
Method: HostAPIMethodBridgesInstancesList,
Params: NamedType{Name: "EmptyResult", Value: EmptyResult{}},
diff --git a/internal/extension/contract/sdk.go b/internal/extension/contract/sdk.go
index 6df77ee0d..5d91d0f4b 100644
--- a/internal/extension/contract/sdk.go
+++ b/internal/extension/contract/sdk.go
@@ -6,6 +6,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
"github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/memory"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/subprocess"
"github.com/pedronauck/agh/internal/tools"
)
@@ -29,6 +30,18 @@ var sdkRootTypes = []NamedType{
{Name: "InitializeExtensionInfo", Value: subprocess.InitializeExtensionInfo{}},
{Name: "AcceptedCapabilities", Value: subprocess.AcceptedCapabilities{}},
{Name: "InitializeSupports", Value: subprocess.InitializeSupports{}},
+ {Name: "ResourceKind", Value: resources.ResourceKind("")},
+ {Name: "ResourceScopeKind", Value: resources.ResourceScopeKind("")},
+ {Name: "ResourceScope", Value: resources.ResourceScope{}},
+ {Name: "ResourceSourceKind", Value: resources.ResourceSourceKind("")},
+ {Name: "ResourceSource", Value: resources.ResourceSource{}},
+ {Name: "ResourceOwnerKind", Value: resources.ResourceOwnerKind("")},
+ {Name: "ResourceOwner", Value: resources.ResourceOwner{}},
+ {Name: "ResourceRecord", Value: ResourceRecord{}},
+ {Name: "ResourceGetParams", Value: ResourceGetParams{}},
+ {Name: "ResourcesListParams", Value: ResourcesListParams{}},
+ {Name: "ResourceSnapshotRecord", Value: ResourceSnapshotRecord{}},
+ {Name: "ResourcesSnapshotParams", Value: ResourcesSnapshotParams{}},
{Name: "ShutdownRequest", Value: subprocess.ShutdownRequest{}},
{Name: "ShutdownResponse", Value: subprocess.ShutdownResponse{}},
{Name: "BridgeInstance", Value: bridgepkg.BridgeInstance{}},
@@ -70,6 +83,16 @@ var sdkRootTypes = []NamedType{
{Name: "ControlPatch", Value: hooks.ControlPatch{}},
{Name: "SessionLifecyclePayload", Value: hooks.SessionLifecyclePayload{}},
{Name: "SessionCreatePatch", Value: hooks.SessionCreatePatch{}},
+ {Name: "EnvironmentProfilePayload", Value: hooks.EnvironmentProfilePayload{}},
+ {Name: "EnvironmentPreparePayload", Value: hooks.EnvironmentPreparePayload{}},
+ {Name: "EnvironmentReadyPayload", Value: hooks.EnvironmentReadyPayload{}},
+ {Name: "EnvironmentSyncBeforePayload", Value: hooks.EnvironmentSyncBeforePayload{}},
+ {Name: "EnvironmentSyncAfterPayload", Value: hooks.EnvironmentSyncAfterPayload{}},
+ {Name: "EnvironmentStopPayload", Value: hooks.EnvironmentStopPayload{}},
+ {Name: "EnvironmentPreparePatch", Value: hooks.EnvironmentPreparePatch{}},
+ {Name: "EnvironmentSyncBeforePatch", Value: hooks.EnvironmentSyncBeforePatch{}},
+ {Name: "EnvironmentObservationPatch", Value: hooks.EnvironmentObservationPatch{}},
+ {Name: "EnvironmentStopPatch", Value: hooks.EnvironmentStopPatch{}},
{Name: "InputPreSubmitPayload", Value: hooks.InputPreSubmitPayload{}},
{Name: "InputPreSubmitPatch", Value: hooks.InputPreSubmitPatch{}},
{Name: "PromptPayload", Value: hooks.PromptPayload{}},
@@ -157,6 +180,30 @@ var namedHookTypes = map[string]NamedType{
"SessionPostResumePatch": {Name: "SessionPostResumePatch", Value: hooks.SessionPostResumePatch{}},
"SessionPreStopPatch": {Name: "SessionPreStopPatch", Value: hooks.SessionPreStopPatch{}},
"SessionPostStopPatch": {Name: "SessionPostStopPatch", Value: hooks.SessionPostStopPatch{}},
+ "EnvironmentProfilePayload": {Name: "EnvironmentProfilePayload", Value: hooks.EnvironmentProfilePayload{}},
+ "EnvironmentPreparePayload": {Name: "EnvironmentPreparePayload", Value: hooks.EnvironmentPreparePayload{}},
+ "EnvironmentReadyPayload": {Name: "EnvironmentReadyPayload", Value: hooks.EnvironmentReadyPayload{}},
+ "EnvironmentSyncBeforePayload": {
+ Name: "EnvironmentSyncBeforePayload",
+ Value: hooks.EnvironmentSyncBeforePayload{},
+ },
+ "EnvironmentSyncAfterPayload": {
+ Name: "EnvironmentSyncAfterPayload",
+ Value: hooks.EnvironmentSyncAfterPayload{},
+ },
+ "EnvironmentStopPayload": {Name: "EnvironmentStopPayload", Value: hooks.EnvironmentStopPayload{}},
+ "EnvironmentPreparePatch": {Name: "EnvironmentPreparePatch", Value: hooks.EnvironmentPreparePatch{}},
+ "EnvironmentSyncBeforePatch": {
+ Name: "EnvironmentSyncBeforePatch",
+ Value: hooks.EnvironmentSyncBeforePatch{},
+ },
+ "EnvironmentObservationPatch": {
+ Name: "EnvironmentObservationPatch",
+ Value: hooks.EnvironmentObservationPatch{},
+ },
+ "EnvironmentReadyPatch": {Name: "EnvironmentReadyPatch", Value: hooks.EnvironmentReadyPatch{}},
+ "EnvironmentSyncAfterPatch": {Name: "EnvironmentSyncAfterPatch", Value: hooks.EnvironmentSyncAfterPatch{}},
+ "EnvironmentStopPatch": {Name: "EnvironmentStopPatch", Value: hooks.EnvironmentStopPatch{}},
"InputPreSubmitPayload": {Name: "InputPreSubmitPayload", Value: hooks.InputPreSubmitPayload{}},
"InputPreSubmitPatch": {Name: "InputPreSubmitPatch", Value: hooks.InputPreSubmitPatch{}},
"PromptPayload": {Name: "PromptPayload", Value: hooks.PromptPayload{}},
diff --git a/internal/extension/describe.go b/internal/extension/describe.go
index a49be71d7..5ed957959 100644
--- a/internal/extension/describe.go
+++ b/internal/extension/describe.go
@@ -8,6 +8,7 @@ import (
const (
extensionStateEnabled = "enabled"
+ extensionStateError = "error"
extensionHealthUnknown = hostAPIUnknownExtensionName
extensionHealthHealthy = "healthy"
extensionHealthUnhealthy = "unhealthy"
@@ -61,7 +62,7 @@ func extensionState(info ExtensionInfo, status ExtensionStatus, daemonRunning bo
return "active"
}
if status.LastError != "" {
- return "error"
+ return extensionStateError
}
if status.Registered {
return "registered"
diff --git a/internal/extension/gchat_provider_integration_test.go b/internal/extension/gchat_provider_integration_test.go
index 92c10b779..e5478f881 100644
--- a/internal/extension/gchat_provider_integration_test.go
+++ b/internal/extension/gchat_provider_integration_test.go
@@ -34,9 +34,11 @@ import (
)
const (
- gchatProviderListenAddrEnv = "AGH_BRIDGE_GCHAT_LISTEN_ADDR"
- gchatProviderAPIBaseEnv = "AGH_BRIDGE_GCHAT_API_BASE_URL"
- gchatProviderTokenURLEnv = "AGH_BRIDGE_GCHAT_TOKEN_URL"
+ gchatProviderListenAddrEnv = "AGH_BRIDGE_GCHAT_LISTEN_ADDR"
+ gchatProviderAPIBaseEnv = "AGH_BRIDGE_GCHAT_API_BASE_URL"
+ gchatProviderTokenURLEnv = "AGH_BRIDGE_GCHAT_TOKEN_URL"
+ gchatProviderDirectCertsEnv = "AGH_BRIDGE_GCHAT_DIRECT_CERTS_URL"
+ gchatProviderPubSubCertsEnv = "AGH_BRIDGE_GCHAT_PUBSUB_CERTS_URL"
gchatProviderDirectIssuer = "chat@system.gserviceaccount.com"
gchatProviderPubSubIssuer = "https://accounts.google.com"
@@ -64,9 +66,7 @@ func TestGChatProviderLaunchNegotiatesBridgeRuntime(t *testing.T) {
ProviderConfig: map[string]any{
"mode": "hybrid",
"verification": map[string]any{
- "direct_certs_url": mockAPI.DirectCertsURL(),
"pubsub_audience": "https://example.test/pubsub",
- "pubsub_certs_url": mockAPI.PubSubCertsURL(),
"pubsub_service_account_email": "push@example.iam.gserviceaccount.com",
},
},
@@ -76,9 +76,11 @@ func TestGChatProviderLaunchNegotiatesBridgeRuntime(t *testing.T) {
},
}},
ExtraEnv: map[string]string{
- gchatProviderListenAddrEnv: listenAddr,
- gchatProviderAPIBaseEnv: mockAPI.URL(),
- gchatProviderTokenURLEnv: mockAPI.TokenURL(),
+ gchatProviderListenAddrEnv: listenAddr,
+ gchatProviderAPIBaseEnv: mockAPI.URL(),
+ gchatProviderTokenURLEnv: mockAPI.TokenURL(),
+ gchatProviderDirectCertsEnv: mockAPI.DirectCertsURL(),
+ gchatProviderPubSubCertsEnv: mockAPI.PubSubCertsURL(),
},
StartTime: time.Date(2026, 4, 15, 20, 30, 0, 0, time.UTC),
})
@@ -134,9 +136,7 @@ func TestGChatProviderIngressAndDeliveryConformance(t *testing.T) {
ProviderConfig: map[string]any{
"mode": "hybrid",
"verification": map[string]any{
- "direct_certs_url": mockAPI.DirectCertsURL(),
"pubsub_audience": "https://example.test/pubsub",
- "pubsub_certs_url": mockAPI.PubSubCertsURL(),
"pubsub_service_account_email": "push@example.iam.gserviceaccount.com",
},
},
@@ -151,9 +151,11 @@ func TestGChatProviderIngressAndDeliveryConformance(t *testing.T) {
{Type: acp.EventTypeDone},
}),
ExtraEnv: map[string]string{
- gchatProviderListenAddrEnv: listenAddr,
- gchatProviderAPIBaseEnv: mockAPI.URL(),
- gchatProviderTokenURLEnv: mockAPI.TokenURL(),
+ gchatProviderListenAddrEnv: listenAddr,
+ gchatProviderAPIBaseEnv: mockAPI.URL(),
+ gchatProviderTokenURLEnv: mockAPI.TokenURL(),
+ gchatProviderDirectCertsEnv: mockAPI.DirectCertsURL(),
+ gchatProviderPubSubCertsEnv: mockAPI.PubSubCertsURL(),
},
StartTime: startTime,
})
diff --git a/internal/extension/host_api.go b/internal/extension/host_api.go
index 13f1fa983..8c9f31a37 100644
--- a/internal/extension/host_api.go
+++ b/internal/extension/host_api.go
@@ -21,6 +21,7 @@ import (
"github.com/pedronauck/agh/internal/frontmatter"
"github.com/pedronauck/agh/internal/memory"
observepkg "github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
@@ -57,6 +58,7 @@ type hostAPIContextKey string
const hostAPIExtensionNameContextKey hostAPIContextKey = "extension.host_api.extension_name"
const hostAPIBridgeRuntimeContextKey hostAPIContextKey = "extension.host_api.bridge_runtime"
+const hostAPIResourceSessionContextKey hostAPIContextKey = "extension.host_api.resource_session"
// HostAPIOption customizes a HostAPIHandler.
type HostAPIOption func(*HostAPIHandler)
@@ -73,9 +75,12 @@ type HostAPIHandler struct {
bridges hostAPIBridgeRegistry
dedupStore hostAPIBridgeDedupStore
deliveryBroker hostAPIDeliveryBroker
+ resourceStore resources.RawStore
+ resourceCodecs *resources.CodecRegistry
capChecker *CapabilityChecker
limiter *hostAPIRateLimiter
automationGetter func() HostAPIAutomationManager
+ resourceTrigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error
now func() time.Time
rateLimit int
rateBurst int
@@ -98,6 +103,7 @@ type hostAPISessionManager interface {
Events(ctx context.Context, id string, query store.EventQuery) ([]store.SessionEvent, error)
Stop(ctx context.Context, id string) error
Prompt(ctx context.Context, id string, msg string) (<-chan acp.AgentEvent, error)
+ ExecEnvironment(ctx context.Context, req session.EnvironmentExecRequest) (session.EnvironmentExecResult, error)
}
type hostAPIObserver interface {
@@ -265,6 +271,32 @@ func WithHostAPIDeliveryBroker(broker hostAPIDeliveryBroker) HostAPIOption {
}
}
+// WithHostAPIResourceStore injects the canonical raw resource store used by
+// the extension resource Host API methods.
+func WithHostAPIResourceStore(store resources.RawStore) HostAPIOption {
+ return func(handler *HostAPIHandler) {
+ handler.resourceStore = store
+ }
+}
+
+// WithHostAPIResourceCodecRegistry injects resource codecs used to validate
+// and canonicalize snapshot specs before persistence.
+func WithHostAPIResourceCodecRegistry(registry *resources.CodecRegistry) HostAPIOption {
+ return func(handler *HostAPIHandler) {
+ handler.resourceCodecs = registry
+ }
+}
+
+// WithHostAPIResourceTrigger injects the reconcile trigger used after
+// successful snapshot writes.
+func WithHostAPIResourceTrigger(
+ trigger func(context.Context, resources.ResourceKind, resources.ReconcileReason) error,
+) HostAPIOption {
+ return func(handler *HostAPIHandler) {
+ handler.resourceTrigger = trigger
+ }
+}
+
// WithHostAPIBridgeIngressConfig overrides dedup TTL and cleanup cadence for bridge ingest.
func WithHostAPIBridgeIngressConfig(dedupTTL time.Duration, cleanupInterval time.Duration) HostAPIOption {
return func(handler *HostAPIHandler) {
@@ -387,10 +419,16 @@ func hostAPIMethodHandlers(handler *HostAPIHandler) map[string]hostAPIMethodFunc
"tasks/runs/complete": handler.handleTasksRunsComplete,
"tasks/runs/fail": handler.handleTasksRunsFail,
"tasks/runs/cancel": handler.handleTasksRunsCancel,
+ "resources/list": handler.handleResourcesList,
+ "resources/get": handler.handleResourcesGet,
+ "resources/snapshot": handler.handleResourcesSnapshot,
"bridges/instances/list": handler.handleBridgesInstancesList,
"bridges/instances/get": handler.handleBridgesInstancesGet,
"bridges/instances/report_state": handler.handleBridgesInstancesReportState,
"bridges/messages/ingest": handler.handleBridgesMessagesIngest,
+ "environment/exec": handler.handleEnvironmentExec,
+ "environment/info": handler.handleEnvironmentInfo,
+ "environment/list": handler.handleEnvironmentList,
"memory/forget": handler.handleMemoryForget,
"memory/recall": handler.handleMemoryRecall,
"memory/store": handler.handleMemoryStore,
@@ -430,10 +468,14 @@ func (h *HostAPIHandler) Handle(
return nil, rpcCapabilityDenied(err)
}
if err := h.limiter.Allow(extName, method); err != nil {
- return nil, err
+ return nil, normalizeHostAPIRPCError(method, err)
}
- return handler(withHostAPIExtensionName(ctx, extName), params)
+ result, err := handler(withHostAPIExtensionName(ctx, extName), params)
+ if err != nil {
+ return nil, normalizeHostAPIRPCError(method, err)
+ }
+ return result, nil
}
// HandleMethod returns a subprocess-compatible handler for one Host API method.
@@ -464,6 +506,41 @@ func withHostAPIBridgeRuntime(ctx context.Context, bridgeRuntime *subprocess.Ini
)
}
+type hostAPIResourceSession struct {
+ Actor resources.MutationActor
+}
+
+func withHostAPIResourceSession(ctx context.Context, session *hostAPIResourceSession) context.Context {
+ if ctx == nil || session == nil {
+ return ctx
+ }
+ cloned := &hostAPIResourceSession{Actor: cloneResourceMutationActor(session.Actor)}
+ return context.WithValue(ctx, hostAPIResourceSessionContextKey, cloned)
+}
+
+func hostAPIResourceSessionFromContext(ctx context.Context) (*hostAPIResourceSession, bool) {
+ if ctx == nil {
+ return nil, false
+ }
+ value, ok := ctx.Value(hostAPIResourceSessionContextKey).(*hostAPIResourceSession)
+ if !ok || value == nil {
+ return nil, false
+ }
+ return &hostAPIResourceSession{Actor: cloneResourceMutationActor(value.Actor)}, true
+}
+
+func cloneResourceMutationActor(actor resources.MutationActor) resources.MutationActor {
+ return resources.MutationActor{
+ Kind: actor.Kind,
+ ID: actor.ID,
+ SessionNonce: actor.SessionNonce,
+ Source: actor.Source,
+ MaxScope: actor.MaxScope,
+ GrantedKinds: append([]resources.ResourceKind(nil), actor.GrantedKinds...),
+ GrantedScopes: append([]resources.ResourceScopeKind(nil), actor.GrantedScopes...),
+ }
+}
+
type hostAPISessionsListParams = extensioncontract.SessionsListParams
type hostAPISessionCreateParams = extensioncontract.SessionsCreateParams
@@ -474,6 +551,12 @@ type hostAPISessionTargetParams = extensioncontract.SessionTargetParams
type hostAPISessionEventsParams = extensioncontract.SessionEventsParams
+type hostAPIEnvironmentListParams = extensioncontract.EnvironmentListParams
+
+type hostAPIEnvironmentInfoParams = extensioncontract.EnvironmentInfoParams
+
+type hostAPIEnvironmentExecParams = extensioncontract.EnvironmentExecParams
+
type hostAPIMemoryStoreParams = extensioncontract.MemoryStoreParams
type hostAPIMemoryRecallParams = extensioncontract.MemoryRecallParams
@@ -494,6 +577,14 @@ type hostAPISessionCreateResult = extensioncontract.SessionCreateResult
type hostAPISessionPromptResult = extensioncontract.SessionPromptResult
+type hostAPIEnvironmentListResult = extensioncontract.EnvironmentListResult
+
+type hostAPIEnvironmentSummary = extensioncontract.EnvironmentSummary
+
+type hostAPIEnvironmentInfoResult = extensioncontract.EnvironmentInfoResult
+
+type hostAPIEnvironmentExecResult = extensioncontract.EnvironmentExecResult
+
type hostAPIMemoryRecallEntry = extensioncontract.MemoryRecallEntry
type hostAPISkillSummary = extensioncontract.SkillSummary
@@ -548,6 +639,12 @@ type hostAPITaskRunFailParams = extensioncontract.TaskRunFailParams
type hostAPITaskRunCancelParams = extensioncontract.TaskRunCancelParams
+type hostAPIResourcesListParams = extensioncontract.ResourcesListParams
+
+type hostAPIResourceGetParams = extensioncontract.ResourceGetParams
+
+type hostAPIResourcesSnapshotParams = extensioncontract.ResourcesSnapshotParams
+
type hostAPIBridgesMessagesIngestParams = extensioncontract.BridgesMessagesIngestParams
type hostAPIBridgesMessagesIngestResult = extensioncontract.BridgesMessagesIngestResult
@@ -558,6 +655,8 @@ type hostAPIBridgesInstancesReportStateParams = extensioncontract.BridgesInstanc
type hostAPIBridgeInstance = bridgepkg.BridgeInstance
+type hostAPIResourceRecord = extensioncontract.ResourceRecord
+
func (h *HostAPIHandler) handleSessionsList(ctx context.Context, raw json.RawMessage) (any, error) {
if h.sessions == nil {
return nil, errors.New("extension: session manager is not configured")
@@ -734,6 +833,155 @@ func (h *HostAPIHandler) handleSessionsEvents(ctx context.Context, raw json.RawM
return result, nil
}
+func (h *HostAPIHandler) handleEnvironmentList(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.sessions == nil {
+ return nil, errors.New("extension: session manager is not configured")
+ }
+
+ var params hostAPIEnvironmentListParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+
+ filterWorkspaceID, filterWorkspaceRoot, err := h.resolveEnvironmentWorkspaceFilter(ctx, params.Workspace)
+ if err != nil {
+ return nil, err
+ }
+
+ infos, err := h.sessions.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result := hostAPIEnvironmentListResult{
+ Environments: make([]hostAPIEnvironmentSummary, 0, len(infos)),
+ }
+ for _, info := range infos {
+ if info == nil || info.Environment == nil || info.State == session.StateStopped {
+ continue
+ }
+ if filterWorkspaceID != "" || filterWorkspaceRoot != "" {
+ if info.WorkspaceID != filterWorkspaceID && info.Workspace != filterWorkspaceRoot {
+ continue
+ }
+ }
+ result.Environments = append(result.Environments, hostAPIEnvironmentSummary{
+ SessionID: info.ID,
+ EnvironmentID: strings.TrimSpace(info.Environment.EnvironmentID),
+ Backend: strings.TrimSpace(info.Environment.Backend),
+ Profile: strings.TrimSpace(info.Environment.Profile),
+ InstanceID: strings.TrimSpace(info.Environment.InstanceID),
+ State: strings.TrimSpace(info.Environment.State),
+ SyncState: hostAPIEnvironmentSyncState(info.Environment),
+ })
+ }
+ return result, nil
+}
+
+func (h *HostAPIHandler) handleEnvironmentInfo(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.sessions == nil {
+ return nil, errors.New("extension: session manager is not configured")
+ }
+ var params hostAPIEnvironmentInfoParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ sessionID := strings.TrimSpace(params.SessionID)
+ if sessionID == "" {
+ return nil, invalidParamsRPCError(errors.New("session_id is required"))
+ }
+ info, err := h.sessions.Status(ctx, sessionID)
+ if err != nil {
+ if errors.Is(err, session.ErrSessionNotFound) {
+ return nil, notFoundRPCError("session", sessionID, err)
+ }
+ return nil, err
+ }
+ if info == nil || info.Environment == nil {
+ return nil, notFoundRPCError("environment", sessionID, errors.New("environment is not configured"))
+ }
+ return hostAPIEnvironmentInfoResult{
+ EnvironmentID: strings.TrimSpace(info.Environment.EnvironmentID),
+ Backend: strings.TrimSpace(info.Environment.Backend),
+ Profile: strings.TrimSpace(info.Environment.Profile),
+ InstanceID: strings.TrimSpace(info.Environment.InstanceID),
+ RuntimeRoot: strings.TrimSpace(info.Environment.RuntimeRootDir),
+ SyncState: hostAPIEnvironmentSyncState(info.Environment),
+ CreatedAt: info.CreatedAt,
+ LastSyncError: strings.TrimSpace(info.Environment.LastSyncError),
+ }, nil
+}
+
+func (h *HostAPIHandler) handleEnvironmentExec(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.sessions == nil {
+ return nil, errors.New("extension: session manager is not configured")
+ }
+ var params hostAPIEnvironmentExecParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ sessionID := strings.TrimSpace(params.SessionID)
+ if sessionID == "" {
+ return nil, invalidParamsRPCError(errors.New("session_id is required"))
+ }
+ command := strings.TrimSpace(params.Command)
+ if command == "" {
+ return nil, invalidParamsRPCError(errors.New("command is required"))
+ }
+ if params.Timeout < 0 {
+ return nil, invalidParamsRPCError(errors.New("timeout must be non-negative"))
+ }
+ result, err := h.sessions.ExecEnvironment(ctx, session.EnvironmentExecRequest{
+ SessionID: sessionID,
+ Command: command,
+ Timeout: time.Duration(params.Timeout) * time.Second,
+ })
+ if err != nil {
+ if errors.Is(err, session.ErrSessionNotFound) {
+ return nil, notFoundRPCError("session", sessionID, err)
+ }
+ if errors.Is(err, session.ErrSessionNotActive) {
+ return nil, unavailableRPCError(err)
+ }
+ return nil, err
+ }
+ return hostAPIEnvironmentExecResult{
+ ExitCode: result.ExitCode,
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ }, nil
+}
+
+func (h *HostAPIHandler) resolveEnvironmentWorkspaceFilter(
+ ctx context.Context,
+ workspace string,
+) (string, string, error) {
+ workspace = strings.TrimSpace(workspace)
+ if workspace == "" {
+ return "", "", nil
+ }
+ if h.workspaces == nil {
+ return workspace, workspace, nil
+ }
+ resolved, err := h.workspaces.Resolve(ctx, workspace)
+ if err != nil {
+ return "", "", err
+ }
+ return strings.TrimSpace(resolved.ID), strings.TrimSpace(resolved.RootDir), nil
+}
+
+func hostAPIEnvironmentSyncState(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ if strings.TrimSpace(meta.LastSyncError) != "" {
+ return extensionStateError
+ }
+ if meta.LastSyncAt != nil {
+ return "synced"
+ }
+ return "pending"
+}
+
func (h *HostAPIHandler) handleMemoryStore(ctx context.Context, raw json.RawMessage) (any, error) {
var params hostAPIMemoryStoreParams
if err := decodeHostAPIParams(raw, ¶ms); err != nil {
@@ -2006,9 +2254,65 @@ func rpcCapabilityDenied(err error) error {
if !errors.As(err, &denied) {
return err
}
+ if isResourceHostAPIMethod(denied.Data.Method) {
+ return hostAPIStatusRPCError(403, "Forbidden", map[string]any{
+ "error": denied.Error(),
+ "method": strings.TrimSpace(denied.Data.Method),
+ "required": append([]string(nil), denied.Data.Required...),
+ "granted": append([]string(nil), denied.Data.Granted...),
+ })
+ }
return subprocess.NewRPCError(denied.Code(), "Capability denied", denied.Data)
}
+func normalizeHostAPIRPCError(method string, err error) error {
+ if err == nil {
+ return nil
+ }
+ if !isResourceHostAPIMethod(method) {
+ return err
+ }
+
+ var rpcErr *subprocess.RPCError
+ if errors.As(err, &rpcErr) {
+ if rpcErr.Code == HostAPIRateLimitedCode {
+ return hostAPIStatusRPCError(429, "Rate limited", rpcErr.Data)
+ }
+ return err
+ }
+
+ switch {
+ case errors.Is(err, resources.ErrPermissionDenied), errors.Is(err, resources.ErrDirectMutationNotAllowed):
+ return hostAPIStatusRPCError(403, "Forbidden", map[string]any{"error": err.Error()})
+ case errors.Is(err, resources.ErrConflict), errors.Is(err, resources.ErrSessionNotActive),
+ errors.Is(err, resources.ErrStaleSourceVersion):
+ return hostAPIStatusRPCError(409, "Conflict", map[string]any{"error": err.Error()})
+ case errors.Is(err, resources.ErrPayloadTooLarge):
+ return hostAPIStatusRPCError(413, "Payload too large", map[string]any{"error": err.Error()})
+ case errors.Is(err, resources.ErrNotFound):
+ return notFoundRPCError("resource", "", err)
+ case errors.Is(err, resources.ErrValidation), errors.Is(err, resources.ErrInvalidScopeBinding):
+ return invalidParamsRPCError(err)
+ default:
+ return err
+ }
+}
+
+func hostAPIStatusRPCError(code int, message string, data any) error {
+ return subprocess.NewRPCError(code, strings.TrimSpace(message), data)
+}
+
+func isResourceHostAPIMethod(method string) bool {
+ switch strings.TrimSpace(method) {
+ case string(extensioncontract.HostAPIMethodResourcesList),
+ string(extensioncontract.HostAPIMethodResourcesGet),
+ string(extensioncontract.HostAPIMethodResourcesSnapshot):
+ return true
+ default:
+ return false
+ }
+}
+
func withHostAPIExtensionName(ctx context.Context, extName string) context.Context {
if ctx == nil {
ctx = context.Background()
diff --git a/internal/extension/host_api_bridges_render_test.go b/internal/extension/host_api_bridges_render_test.go
new file mode 100644
index 000000000..82a3b0291
--- /dev/null
+++ b/internal/extension/host_api_bridges_render_test.go
@@ -0,0 +1,92 @@
+package extensionpkg
+
+import (
+ "strings"
+ "testing"
+
+ bridgepkg "github.com/pedronauck/agh/internal/bridges"
+)
+
+func TestRenderInboundMessageFamilyLines(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ family bridgepkg.InboundEventFamily
+ envelope bridgepkg.InboundMessageEnvelope
+ want []string
+ }{
+ {
+ name: "Command family renders command details",
+ family: bridgepkg.InboundEventFamilyCommand,
+ envelope: bridgepkg.InboundMessageEnvelope{
+ Command: &bridgepkg.InboundCommand{
+ Command: "deploy",
+ Text: "--force",
+ TriggerID: "trg-1",
+ },
+ },
+ want: []string{"Inbound bridge command", "Command: deploy", "Arguments: --force", "Trigger ID: trg-1"},
+ },
+ {
+ name: "Action family renders action details",
+ family: bridgepkg.InboundEventFamilyAction,
+ envelope: bridgepkg.InboundMessageEnvelope{
+ Action: &bridgepkg.InboundAction{
+ ActionID: "approve",
+ MessageID: "msg-1",
+ Value: "yes",
+ TriggerID: "trg-2",
+ },
+ },
+ want: []string{
+ "Inbound bridge action",
+ "Action ID: approve",
+ "Message ID: msg-1",
+ "Value: yes",
+ "Trigger ID: trg-2",
+ },
+ },
+ {
+ name: "Reaction family renders reaction details",
+ family: bridgepkg.InboundEventFamilyReaction,
+ envelope: bridgepkg.InboundMessageEnvelope{
+ Reaction: &bridgepkg.InboundReaction{
+ MessageID: "msg-2",
+ Emoji: ":eyes:",
+ RawEmoji: "U+1F440",
+ Added: false,
+ },
+ },
+ want: []string{
+ "Inbound bridge reaction",
+ "Message ID: msg-2",
+ "Emoji: :eyes:",
+ "Raw emoji: U+1F440",
+ "Change: removed",
+ },
+ },
+ {
+ name: "Unknown family falls back to generic rendering",
+ family: bridgepkg.InboundEventFamily("custom"),
+ envelope: bridgepkg.InboundMessageEnvelope{
+ PlatformMessageID: "msg-3",
+ },
+ want: []string{"Inbound bridge message", "Platform message ID: msg-3"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ lines := renderInboundMessageFamilyLines(tc.family, tc.envelope)
+ rendered := strings.Join(lines, "\n")
+ for _, want := range tc.want {
+ if !strings.Contains(rendered, want) {
+ t.Fatalf("rendered lines = %q, want substring %q", rendered, want)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/extension/host_api_integration_test.go b/internal/extension/host_api_integration_test.go
index 25330ee06..246cadeb8 100644
--- a/internal/extension/host_api_integration_test.go
+++ b/internal/extension/host_api_integration_test.go
@@ -14,6 +14,7 @@ import (
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
taskpkg "github.com/pedronauck/agh/internal/task"
"github.com/pedronauck/agh/internal/testutil"
@@ -116,6 +117,254 @@ func TestHostAPIIntegrationStoresAndRecallsMemory(t *testing.T) {
}
}
+func TestHostAPIIntegrationResourcesSnapshotPublishesAndReadsBack(t *testing.T) {
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "ext-resources",
+ []string{"resources/list", "resources/get", "resources/snapshot"},
+ []string{"resource.read", "resource.write"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-integration"
+ env.activateResourceSession(t, "ext-resources", sessionNonce)
+
+ if _, err := env.callResource(t, "ext-resources", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", "extension"),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("Handle(resources/snapshot) error = %v", err)
+ }
+
+ listResult, err := env.callResource(t, "ext-resources", sessionNonce, "resources/list", map[string]any{
+ "kind": "tool",
+ })
+ if err != nil {
+ t.Fatalf("Handle(resources/list) error = %v", err)
+ }
+
+ var listed []hostAPIResourceRecord
+ decodeResult(t, listResult, &listed)
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(resources/list) = %d, want %d", got, want)
+ }
+ if got, want := listed[0].ID, "grep"; got != want {
+ t.Fatalf("resources/list[0].ID = %q, want %q", got, want)
+ }
+
+ getResult, err := env.callResource(t, "ext-resources", sessionNonce, "resources/get", map[string]any{
+ "kind": "tool",
+ "id": "grep",
+ })
+ if err != nil {
+ t.Fatalf("Handle(resources/get) error = %v", err)
+ }
+
+ var record hostAPIResourceRecord
+ decodeResult(t, getResult, &record)
+ if got, want := record.Version, int64(1); got != want {
+ t.Fatalf("resources/get version = %d, want %d", got, want)
+ }
+}
+
+func TestHostAPIIntegrationBridgeProviderKeepsOperationalMethodsAlongsideGenericResourceReads(t *testing.T) {
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "telegram-adapter",
+ []string{"resources/list", "resources/get", "bridges/instances/list", "bridges/instances/get"},
+ []string{"resource.read", "bridge.read"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-bridge-provider"
+ env.activateResourceSession(t, "telegram-adapter", sessionNonce)
+
+ instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{
+ ID: "brg-resource-coexist",
+ ExtensionName: "telegram-adapter",
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+ ctx := env.bridgeContext(t, instance)
+
+ listResult, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/list", nil)
+ if err != nil {
+ t.Fatalf("Handle(bridges/instances/list) error = %v", err)
+ }
+
+ var listed []hostAPIBridgeInstance
+ decodeResult(t, listResult, &listed)
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(bridges/instances/list) = %d, want %d", got, want)
+ }
+ if got, want := listed[0].ID, instance.ID; got != want {
+ t.Fatalf("bridges/instances/list[0].ID = %q, want %q", got, want)
+ }
+
+ getResult, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/get", map[string]any{
+ "bridge_instance_id": instance.ID,
+ })
+ if err != nil {
+ t.Fatalf("Handle(bridges/instances/get) error = %v", err)
+ }
+
+ var loaded hostAPIBridgeInstance
+ decodeResult(t, getResult, &loaded)
+ if got, want := loaded.ID, instance.ID; got != want {
+ t.Fatalf("bridges/instances/get id = %q, want %q", got, want)
+ }
+
+ resourceListResult, err := env.callResource(
+ t,
+ "telegram-adapter",
+ sessionNonce,
+ "resources/list",
+ map[string]any{"kind": "tool"},
+ )
+ if err != nil {
+ t.Fatalf("Handle(resources/list) error = %v", err)
+ }
+
+ var resourcesListed []hostAPIResourceRecord
+ decodeResult(t, resourceListResult, &resourcesListed)
+ if got := len(resourcesListed); got != 0 {
+ t.Fatalf("len(resources/list tool) = %d, want 0", got)
+ }
+
+ _, err = env.callResource(t, "telegram-adapter", sessionNonce, "resources/get", map[string]any{
+ "kind": "bridge.instance",
+ "id": instance.ID,
+ })
+ assertRPCErrorCode(t, err, 403)
+}
+
+func TestHostAPIIntegrationSecondResourceSessionInvalidatesOlderNonce(t *testing.T) {
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "ext-resources",
+ []string{"resources/snapshot"},
+ []string{"resource.write"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ firstNonce := "nonce-first"
+ env.activateResourceSession(t, "ext-resources", firstNonce)
+ if _, err := env.callResource(t, "ext-resources", firstNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", "extension"),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("first Handle(resources/snapshot) error = %v", err)
+ }
+
+ secondNonce := "nonce-second"
+ env.activateResourceSession(t, "ext-resources", secondNonce)
+
+ if _, err := env.callResource(t, "ext-resources", secondNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace v2", "extension"),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("second Handle(resources/snapshot) error = %v", err)
+ }
+
+ _, err := env.callResource(t, "ext-resources", firstNonce, "resources/snapshot", map[string]any{
+ "source_version": 2,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "stale workspace search", "extension"),
+ },
+ },
+ })
+ assertRPCErrorCode(t, err, 409)
+}
+
+func TestHostAPIIntegrationResourceSnapshotReplacesToolSetAndRemovesStaleTools(t *testing.T) {
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "ext-resources",
+ []string{"resources/list", "resources/snapshot"},
+ []string{"resource.read", "resource.write"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-replace"
+ env.activateResourceSession(t, "ext-resources", sessionNonce)
+
+ if _, err := env.callResource(t, "ext-resources", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", "extension"),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("first Handle(resources/snapshot) error = %v", err)
+ }
+
+ if _, err := env.callResource(t, "ext-resources", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 2,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "lookup",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("lookup", "lookup workspace", "extension"),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("second Handle(resources/snapshot) error = %v", err)
+ }
+
+ listResult, err := env.callResource(t, "ext-resources", sessionNonce, "resources/list", map[string]any{
+ "kind": "tool",
+ })
+ if err != nil {
+ t.Fatalf("Handle(resources/list) error = %v", err)
+ }
+
+ var listed []hostAPIResourceRecord
+ decodeResult(t, listResult, &listed)
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(resources/list) = %d, want %d", got, want)
+ }
+ if got, want := listed[0].ID, "lookup"; got != want {
+ t.Fatalf("resources/list[0].ID = %q, want %q", got, want)
+ }
+}
+
func TestHostAPIIntegrationExtensionCanCreateTaskAndEnqueueRun(t *testing.T) {
env := newHostAPITestEnv(t)
env.grant(
@@ -280,7 +529,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestCreatesRouteAndSession(t *testin
})
ctx := env.bridgeContext(t, instance)
- result, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", map[string]any{
+ result, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", map[string]any{
"bridge_instance_id": instance.ID,
"scope": instance.Scope,
"workspace_id": instance.WorkspaceID,
@@ -327,7 +576,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestSupportsSiblingInstancesInOneRun
})
ctx := env.bridgeContextForInstances(t, first, second)
- result, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", map[string]any{
+ result, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", map[string]any{
"bridge_instance_id": second.ID,
"scope": second.Scope,
"workspace_id": second.WorkspaceID,
@@ -384,7 +633,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestDuplicateRetryIsSuppressed(t *te
"content": map[string]any{"text": "retry me"},
}
- first, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", params)
+ first, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", params)
if err != nil {
t.Fatalf("first Handle(bridges/messages/ingest) error = %v", err)
}
@@ -393,7 +642,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestDuplicateRetryIsSuppressed(t *te
env.advanceTime(2 * time.Minute)
- second, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", params)
+ second, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", params)
if err != nil {
t.Fatalf("retry Handle(bridges/messages/ingest) error = %v", err)
}
@@ -430,7 +679,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestRejectsNonOwnedInstance(t *testi
})
ctx := env.bridgeContext(t, owned)
- _, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", map[string]any{
+ _, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", map[string]any{
"bridge_instance_id": foreign.ID,
"scope": foreign.Scope,
"workspace_id": foreign.WorkspaceID,
@@ -454,7 +703,7 @@ func TestHostAPIIntegrationBridgesInstancesReportStatePublishesAuthRequired(t *t
})
ctx := env.bridgeContext(t, instance)
- result, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/instances/report_state", map[string]any{
+ result, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/report_state", map[string]any{
"bridge_instance_id": instance.ID,
"status": "auth_required",
"degradation": map[string]any{
@@ -475,7 +724,7 @@ func TestHostAPIIntegrationBridgesInstancesReportStatePublishesAuthRequired(t *t
t.Fatalf("bridges/instances/report_state degradation = %#v, want auth_failed", updated.Degradation)
}
- fetched, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/instances/get", map[string]any{
+ fetched, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/get", map[string]any{
"bridge_instance_id": instance.ID,
})
if err != nil {
@@ -511,7 +760,7 @@ func TestHostAPIIntegrationBridgesInstancesListAndGetReturnOwnedInstances(t *tes
ctx := env.bridgeContextForInstances(t, first, second)
- listedResult, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/instances/list", nil)
+ listedResult, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/list", nil)
if err != nil {
t.Fatalf("Handle(bridges/instances/list) error = %v", err)
}
@@ -525,7 +774,7 @@ func TestHostAPIIntegrationBridgesInstancesListAndGetReturnOwnedInstances(t *tes
t.Fatalf("bridges/instances/list ids = %#v, want %#v", got, want)
}
- fetchedResult, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/instances/get", map[string]any{
+ fetchedResult, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/get", map[string]any{
"bridge_instance_id": second.ID,
})
if err != nil {
@@ -561,7 +810,7 @@ func TestHostAPIIntegrationBridgesMessagesIngestConcurrentSameRoutingKeyUsesOneR
idx := idx
go func() {
defer func() { done <- struct{}{} }()
- result, err := env.callWithContext(t, ctx, "telegram-adapter", "bridges/messages/ingest", map[string]any{
+ result, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", map[string]any{
"bridge_instance_id": instance.ID,
"scope": instance.Scope,
"workspace_id": instance.WorkspaceID,
diff --git a/internal/extension/host_api_resources.go b/internal/extension/host_api_resources.go
new file mode 100644
index 000000000..eeeace40d
--- /dev/null
+++ b/internal/extension/host_api_resources.go
@@ -0,0 +1,156 @@
+package extensionpkg
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+
+ extensioncontract "github.com/pedronauck/agh/internal/extension/contract"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+func (h *HostAPIHandler) handleResourcesList(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.resourceStore == nil {
+ return nil, unavailableRPCError(errors.New("extension: resource store is not configured"))
+ }
+
+ var params hostAPIResourcesListParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+
+ actor, err := hostAPIResourceActorFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ records, err := h.resourceStore.ListRaw(ctx, actor, resources.ResourceFilter{
+ Kind: params.Kind,
+ Scope: params.Scope,
+ Limit: params.Limit,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return mapHostAPIResourceRecords(records), nil
+}
+
+func (h *HostAPIHandler) handleResourcesGet(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.resourceStore == nil {
+ return nil, unavailableRPCError(errors.New("extension: resource store is not configured"))
+ }
+
+ var params hostAPIResourceGetParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+
+ actor, err := hostAPIResourceActorFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ record, err := h.resourceStore.GetRaw(ctx, actor, params.Kind, params.ID)
+ if err != nil {
+ if errors.Is(err, resources.ErrNotFound) {
+ return nil, notFoundRPCError("resource", params.ID, err)
+ }
+ return nil, err
+ }
+ return hostAPIResourceRecordFromRaw(record), nil
+}
+
+func (h *HostAPIHandler) handleResourcesSnapshot(ctx context.Context, raw json.RawMessage) (any, error) {
+ if h.resourceStore == nil {
+ return nil, unavailableRPCError(errors.New("extension: resource store is not configured"))
+ }
+
+ var params hostAPIResourcesSnapshotParams
+ if err := decodeHostAPIParams(raw, ¶ms); err != nil {
+ return nil, err
+ }
+
+ actor, err := hostAPIResourceActorFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ drafts := make([]resources.RawDraft, 0, len(params.Records))
+ for _, record := range params.Records {
+ spec := append([]byte(nil), record.Spec...)
+ if h.resourceCodecs != nil {
+ canonical, _, err := resources.ValidateAndCanonicalizeIfRegistered(
+ ctx,
+ h.resourceCodecs,
+ record.Kind,
+ record.Scope,
+ spec,
+ )
+ if err != nil {
+ return nil, err
+ }
+ spec = canonical
+ }
+ drafts = append(drafts, resources.RawDraft{
+ Kind: record.Kind,
+ ID: record.ID,
+ Scope: record.Scope,
+ ExpectedVersion: 0,
+ SpecJSON: spec,
+ })
+ }
+
+ if err := h.resourceStore.ApplySourceSnapshotRaw(ctx, actor, resources.SourceSnapshot{
+ SourceVersion: params.SourceVersion,
+ Records: drafts,
+ }); err != nil {
+ return nil, err
+ }
+ if h.resourceTrigger != nil {
+ for _, grantedKind := range actor.GrantedKinds {
+ kind := grantedKind.Normalize()
+ if kind == "" {
+ continue
+ }
+ if err := h.resourceTrigger(ctx, kind, resources.ReconcileReasonWrite); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return extensioncontract.EmptyResult{}, nil
+}
+
+func hostAPIResourceActorFromContext(ctx context.Context) (resources.MutationActor, error) {
+ session, ok := hostAPIResourceSessionFromContext(ctx)
+ if !ok || session == nil {
+ return resources.MutationActor{}, unavailableRPCError(errors.New("extension: resource session is not active"))
+ }
+ return cloneResourceMutationActor(session.Actor), nil
+}
+
+func mapHostAPIResourceRecords(records []resources.RawRecord) []hostAPIResourceRecord {
+ if len(records) == 0 {
+ return []hostAPIResourceRecord{}
+ }
+
+ mapped := make([]hostAPIResourceRecord, 0, len(records))
+ for _, record := range records {
+ mapped = append(mapped, hostAPIResourceRecordFromRaw(record))
+ }
+ return mapped
+}
+
+func hostAPIResourceRecordFromRaw(record resources.RawRecord) hostAPIResourceRecord {
+ return hostAPIResourceRecord{
+ Kind: record.Kind,
+ ID: record.ID,
+ Version: record.Version,
+ Scope: record.Scope,
+ Owner: record.Owner,
+ Source: record.Source,
+ Spec: append(json.RawMessage(nil), record.SpecJSON...),
+ CreatedAt: record.CreatedAt,
+ UpdatedAt: record.UpdatedAt,
+ }
+}
diff --git a/internal/extension/host_api_test.go b/internal/extension/host_api_test.go
index 3acf76a24..254623d68 100644
--- a/internal/extension/host_api_test.go
+++ b/internal/extension/host_api_test.go
@@ -21,10 +21,13 @@ import (
automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
"github.com/pedronauck/agh/internal/extension/protocol"
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/memory"
observepkg "github.com/pedronauck/agh/internal/observe"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/session"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
@@ -33,6 +36,7 @@ import (
"github.com/pedronauck/agh/internal/subprocess"
taskpkg "github.com/pedronauck/agh/internal/task"
"github.com/pedronauck/agh/internal/testutil"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -188,6 +192,291 @@ func TestHostAPIHandlerSessionsStatusReturnsAuthorizedState(t *testing.T) {
}
}
+func TestHostAPIHandlerEnvironmentListReturnsActiveEnvironmentInstances(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-list", []string{"environment/list"}, nil)
+ sess := env.createSession(t)
+
+ result, err := env.call(t, "ext-env-list", "environment/list", nil)
+ if err != nil {
+ t.Fatalf("Handle(environment/list) error = %v", err)
+ }
+
+ var listed hostAPIEnvironmentListResult
+ decodeResult(t, result, &listed)
+ if len(listed.Environments) != 1 {
+ t.Fatalf("len(environment/list) = %d, want 1", len(listed.Environments))
+ }
+ got := listed.Environments[0]
+ if got.SessionID != sess.ID {
+ t.Fatalf("environment/list session_id = %q, want %q", got.SessionID, sess.ID)
+ }
+ if got.EnvironmentID == "" {
+ t.Fatal("environment/list environment_id = empty, want allocated id")
+ }
+ if got.Backend != string(environment.BackendLocal) {
+ t.Fatalf("environment/list backend = %q, want local", got.Backend)
+ }
+ if got.SyncState != "synced" {
+ t.Fatalf("environment/list sync_state = %q, want synced", got.SyncState)
+ }
+}
+
+func TestHostAPIHandlerEnvironmentListFiltersWorkspaceAndSkipsStopped(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-list-filtered", []string{"environment/list"}, nil)
+ stopped := env.createSession(t)
+ active := env.createSession(t)
+ if err := env.sessions.Stop(testutil.Context(t), stopped.ID); err != nil {
+ t.Fatalf("sessions.Stop(%q) error = %v", stopped.ID, err)
+ }
+
+ result, err := env.call(
+ t,
+ "ext-env-list-filtered",
+ "environment/list",
+ map[string]string{"workspace": env.workspace.Name},
+ )
+ if err != nil {
+ t.Fatalf("Handle(environment/list filtered) error = %v", err)
+ }
+
+ var listed hostAPIEnvironmentListResult
+ decodeResult(t, result, &listed)
+ if len(listed.Environments) != 1 {
+ t.Fatalf("len(environment/list filtered) = %d, want 1", len(listed.Environments))
+ }
+ if got := listed.Environments[0].SessionID; got != active.ID {
+ t.Fatalf("environment/list filtered session_id = %q, want active session %q", got, active.ID)
+ }
+}
+
+func TestHostAPIHandlerEnvironmentInfoReturnsRuntimeState(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-info", []string{"environment/info"}, nil)
+ sess := env.createSession(t)
+
+ meta := sess.Info().Environment
+ if meta == nil {
+ t.Fatal("session environment = nil, want prepared environment")
+ }
+
+ result, err := env.call(t, "ext-env-info", "environment/info", map[string]string{"session_id": sess.ID})
+ if err != nil {
+ t.Fatalf("Handle(environment/info) error = %v", err)
+ }
+
+ var info hostAPIEnvironmentInfoResult
+ decodeResult(t, result, &info)
+ if info.EnvironmentID != meta.EnvironmentID {
+ t.Fatalf("environment/info environment_id = %q, want %q", info.EnvironmentID, meta.EnvironmentID)
+ }
+ if info.RuntimeRoot != meta.RuntimeRootDir {
+ t.Fatalf("environment/info runtime_root = %q, want %q", info.RuntimeRoot, meta.RuntimeRootDir)
+ }
+ if info.SyncState != "synced" {
+ t.Fatalf("environment/info sync_state = %q, want synced", info.SyncState)
+ }
+ if info.LastSyncError != "" {
+ t.Fatalf("environment/info last_sync_error = %q, want empty", info.LastSyncError)
+ }
+ var raw map[string]any
+ decodeResult(t, result, &raw)
+ if _, ok := raw["last_sync_error"]; !ok {
+ t.Fatalf("environment/info result keys = %#v, want last_sync_error key", raw)
+ }
+}
+
+func TestHostAPIHandlerEnvironmentInfoReturnsNotFoundForInvalidSession(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-info", []string{"environment/info"}, nil)
+
+ _, err := env.call(t, "ext-env-info", "environment/info", map[string]string{"session_id": "missing"})
+ assertRPCErrorCode(t, err, HostAPINotFoundCode)
+}
+
+func TestHostAPIHandlerEnvironmentInfoValidatesSessionID(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-info-invalid", []string{"environment/info"}, nil)
+
+ _, err := env.call(t, "ext-env-info-invalid", "environment/info", map[string]string{"session_id": " "})
+ assertRPCErrorCode(t, err, HostAPIInvalidParamsCode)
+}
+
+func TestHostAPIEnvironmentSyncStateValues(t *testing.T) {
+ t.Parallel()
+
+ now := time.Now().UTC()
+ tests := []struct {
+ name string
+ meta *store.SessionEnvironmentMeta
+ want string
+ }{
+ {name: "nil", want: ""},
+ {name: "pending", meta: &store.SessionEnvironmentMeta{}, want: "pending"},
+ {name: "synced", meta: &store.SessionEnvironmentMeta{LastSyncAt: &now}, want: "synced"},
+ {name: "error", meta: &store.SessionEnvironmentMeta{LastSyncError: "failed"}, want: extensionStateError},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := hostAPIEnvironmentSyncState(tc.meta); got != tc.want {
+ t.Fatalf("hostAPIEnvironmentSyncState() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestHostAPIHandlerResolveEnvironmentWorkspaceFilter(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t)
+ handler := &HostAPIHandler{}
+ id, root, err := handler.resolveEnvironmentWorkspaceFilter(ctx, " workspace-raw ")
+ if err != nil {
+ t.Fatalf("resolveEnvironmentWorkspaceFilter(raw) error = %v", err)
+ }
+ if id != "workspace-raw" || root != "workspace-raw" {
+ t.Fatalf("resolveEnvironmentWorkspaceFilter(raw) = (%q, %q), want raw fallback", id, root)
+ }
+
+ workspace := &workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{
+ ID: "ws-id",
+ Name: "workspace-name",
+ RootDir: filepath.Join(t.TempDir(), "workspace"),
+ },
+ }
+ handler.workspaces = newHostAPIFakeWorkspaceResolver(workspace)
+ id, root, err = handler.resolveEnvironmentWorkspaceFilter(ctx, "workspace-name")
+ if err != nil {
+ t.Fatalf("resolveEnvironmentWorkspaceFilter(resolved) error = %v", err)
+ }
+ if id != workspace.ID || root != workspace.RootDir {
+ t.Fatalf("resolveEnvironmentWorkspaceFilter(resolved) = (%q, %q), want (%q, %q)",
+ id,
+ root,
+ workspace.ID,
+ workspace.RootDir,
+ )
+ }
+
+ if _, _, err := handler.resolveEnvironmentWorkspaceFilter(ctx, "missing"); err == nil {
+ t.Fatal("resolveEnvironmentWorkspaceFilter(missing) error = nil, want error")
+ }
+}
+
+func TestHostAPIHandlerEnvironmentMethodsRequireSessionManager(t *testing.T) {
+ t.Parallel()
+
+ handler := &HostAPIHandler{}
+ ctx := testutil.Context(t)
+ for _, method := range []struct {
+ name string
+ call func(context.Context, json.RawMessage) (any, error)
+ }{
+ {name: "list", call: handler.handleEnvironmentList},
+ {name: "info", call: handler.handleEnvironmentInfo},
+ {name: "exec", call: handler.handleEnvironmentExec},
+ } {
+ t.Run(method.name, func(t *testing.T) {
+ if _, err := method.call(ctx, nil); err == nil {
+ t.Fatal("environment Host API handler error = nil, want missing session manager error")
+ }
+ })
+ }
+}
+
+func TestHostAPIHandlerEnvironmentExecRequiresExecCapability(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-exec-denied", []string{"environment/exec"}, nil)
+ sess := env.createSession(t)
+
+ _, err := env.call(t, "ext-env-exec-denied", "environment/exec", map[string]any{
+ "session_id": sess.ID,
+ "command": "printf denied",
+ "timeout": 1,
+ })
+ assertCapabilityDenied(t, err, "environment/exec")
+}
+
+func TestHostAPIHandlerEnvironmentExecRunsCommandInEnvironment(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-exec", []string{"environment/exec"}, []string{"environment.exec"})
+ sess := env.createSession(t)
+
+ result, err := env.call(t, "ext-env-exec", "environment/exec", map[string]any{
+ "session_id": sess.ID,
+ "command": "printf host-api-env",
+ "timeout": 5,
+ })
+ if err != nil {
+ t.Fatalf("Handle(environment/exec) error = %v", err)
+ }
+
+ var execResult hostAPIEnvironmentExecResult
+ decodeResult(t, result, &execResult)
+ if execResult.ExitCode != 0 {
+ t.Fatalf("environment/exec exit_code = %d, want 0", execResult.ExitCode)
+ }
+ if strings.TrimSpace(execResult.Stdout) != "host-api-env" {
+ t.Fatalf("environment/exec stdout = %q, want host-api-env", execResult.Stdout)
+ }
+ if execResult.Stderr != "" {
+ t.Fatalf("environment/exec stderr = %q, want empty", execResult.Stderr)
+ }
+}
+
+func TestHostAPIHandlerEnvironmentExecValidatesParams(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grant("ext-env-exec-invalid", []string{"environment/exec"}, []string{"environment.exec"})
+
+ tests := []struct {
+ name string
+ params map[string]any
+ }{
+ {
+ name: "missing session id",
+ params: map[string]any{"command": "pwd"},
+ },
+ {
+ name: "missing command",
+ params: map[string]any{"session_id": "sess-1"},
+ },
+ {
+ name: "negative timeout",
+ params: map[string]any{
+ "session_id": "sess-1",
+ "command": "pwd",
+ "timeout": -1,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := env.call(t, "ext-env-exec-invalid", "environment/exec", tc.params)
+ assertRPCErrorCode(t, err, HostAPIInvalidParamsCode)
+ })
+ }
+}
+
func TestHostAPIHandlerSessionsEventsSupportsSinceFilter(t *testing.T) {
t.Parallel()
@@ -278,6 +567,195 @@ func TestHostAPIHandlerSessionsMethodsRequireConfiguredManager(t *testing.T) {
}
}
+func TestHostAPIHandlerResourcesListAndGetEnforceSameSourceAndGrantedKinds(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "ext-resources",
+ []string{"resources/list", "resources/get", "resources/snapshot"},
+ []string{"resource.read", "resource.write"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-resources"
+ env.activateResourceSession(t, "ext-resources", sessionNonce)
+
+ if _, err := env.callResource(t, "ext-resources", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", toolspkg.ToolSourceExtension.String()),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("Handle(resources/snapshot) error = %v", err)
+ }
+
+ if _, err := env.resources.PutRaw(testutil.Context(t), resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "host-api-tests",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "host-api-tests",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }, resources.RawDraft{
+ Kind: resources.ResourceKind("tool"),
+ ID: "foreign",
+ Scope: resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: env.workspaceID},
+ SpecJSON: mustMarshalJSON(t, map[string]any{
+ "command": "foreign",
+ }),
+ }); err != nil {
+ t.Fatalf("PutRaw(foreign) error = %v", err)
+ }
+
+ listResult, err := env.callResource(t, "ext-resources", sessionNonce, "resources/list", map[string]any{
+ "kind": "tool",
+ })
+ if err != nil {
+ t.Fatalf("Handle(resources/list) error = %v", err)
+ }
+
+ var listed []hostAPIResourceRecord
+ decodeResult(t, listResult, &listed)
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(resources/list) = %d, want %d", got, want)
+ }
+ if got, want := listed[0].ID, "grep"; got != want {
+ t.Fatalf("resources/list[0].id = %q, want %q", got, want)
+ }
+
+ getResult, err := env.callResource(t, "ext-resources", sessionNonce, "resources/get", map[string]any{
+ "kind": "tool",
+ "id": "grep",
+ })
+ if err != nil {
+ t.Fatalf("Handle(resources/get own) error = %v", err)
+ }
+
+ var own hostAPIResourceRecord
+ decodeResult(t, getResult, &own)
+ if got, want := own.ID, "grep"; got != want {
+ t.Fatalf("resources/get own id = %q, want %q", got, want)
+ }
+
+ _, err = env.callResource(t, "ext-resources", sessionNonce, "resources/get", map[string]any{
+ "kind": "tool",
+ "id": "foreign",
+ })
+ assertRPCErrorCode(t, err, 403)
+
+ _, err = env.callResource(t, "ext-resources", sessionNonce, "resources/list", map[string]any{
+ "kind": "mcp_server",
+ })
+ assertRPCErrorCode(t, err, 403)
+}
+
+func TestHostAPIHandlerResourcesSnapshotRejectsStaleVersionAndInactiveNonce(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "ext-snapshot",
+ []string{"resources/snapshot"},
+ []string{"resource.write"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-active"
+ env.activateResourceSession(t, "ext-snapshot", sessionNonce)
+
+ params := map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", toolspkg.ToolSourceExtension.String()),
+ },
+ },
+ }
+ if _, err := env.callResource(t, "ext-snapshot", sessionNonce, "resources/snapshot", params); err != nil {
+ t.Fatalf("first Handle(resources/snapshot) error = %v", err)
+ }
+
+ _, err := env.callResource(t, "ext-snapshot", sessionNonce, "resources/snapshot", params)
+ assertRPCErrorCode(t, err, 409)
+
+ env.activateResourceSession(t, "ext-snapshot", "nonce-next")
+
+ _, err = env.callResource(t, "ext-snapshot", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 2,
+ "records": params["records"],
+ })
+ assertRPCErrorCode(t, err, 409)
+}
+
+func TestHostAPIHandlerResourcesMethodsCoexistWithBridgeOperationalMethods(t *testing.T) {
+ t.Parallel()
+
+ env := newHostAPITestEnv(t)
+ env.grantWithResources(
+ t,
+ "telegram-adapter",
+ []string{"resources/list", "bridges/instances/list", "bridges/instances/get"},
+ []string{"resource.read", "bridge.read"},
+ []string{"tools"},
+ resources.ResourceScopeKindWorkspace,
+ )
+
+ sessionNonce := "nonce-bridge"
+ env.activateResourceSession(t, "telegram-adapter", sessionNonce)
+ if _, err := env.callResource(t, "telegram-adapter", sessionNonce, "resources/snapshot", map[string]any{
+ "source_version": 1,
+ "records": []map[string]any{
+ {
+ "kind": "tool",
+ "id": "grep",
+ "scope": map[string]any{"kind": "workspace", "id": env.workspaceID},
+ "spec": hostAPITestToolSpec("grep", "search workspace", toolspkg.ToolSourceExtension.String()),
+ },
+ },
+ }); err == nil {
+ t.Fatal("Handle(resources/snapshot) error = nil, want capability denial without resources/snapshot action")
+ } else {
+ assertRPCErrorCode(t, err, 403)
+ }
+
+ instance := env.createBridgeInstance(t, bridgepkg.CreateInstanceRequest{
+ ID: "brg-coexist",
+ ExtensionName: "telegram-adapter",
+ RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
+ })
+ ctx := env.bridgeContext(t, instance)
+
+ listedResult, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/instances/list", nil)
+ if err != nil {
+ t.Fatalf("Handle(bridges/instances/list) error = %v", err)
+ }
+
+ var listed []hostAPIBridgeInstance
+ decodeResult(t, listedResult, &listed)
+ if got, want := len(listed), 1; got != want {
+ t.Fatalf("len(bridges/instances/list) = %d, want %d", got, want)
+ }
+
+ _, err = env.callResource(t, "telegram-adapter", sessionNonce, "resources/list", map[string]any{
+ "kind": "bridge.instance",
+ })
+ assertRPCErrorCode(t, err, 403)
+}
+
func TestHostAPIHandlerMemoryStorePersistsContentWithTags(t *testing.T) {
t.Parallel()
@@ -470,8 +948,6 @@ func TestHostAPIHandlerBridgesMessagesIngestRejectsInvalidPayloads(t *testing.T)
ID: "brg-ingest-invalid",
RoutingPolicy: bridgepkg.RoutingPolicy{IncludePeer: true},
})
- ctx := env.bridgeContext(t, instance)
-
tests := []struct {
name string
params map[string]any
@@ -515,6 +991,7 @@ func TestHostAPIHandlerBridgesMessagesIngestRejectsInvalidPayloads(t *testing.T)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
+ ctx := env.bridgeContext(t, instance)
_, err := env.callWithContext(ctx, t, "telegram-adapter", "bridges/messages/ingest", tt.params)
assertRPCErrorCode(t, err, tt.wantCode)
assertErrorContains(t, err, tt.wantText)
@@ -1418,7 +1895,13 @@ func TestManagerWrapHostHandlerInjectsExtensionNameForHostAPIHandler(t *testing.
env.grant("ext-wrapped", []string{"observe/health"}, []string{"observe.read"})
manager := NewManager(nil, WithCapabilityChecker(env.checker))
- wrapped := manager.wrapHostHandler("ext-wrapped", "observe/health", nil, env.handler.HandleMethod("observe/health"))
+ wrapped := manager.wrapHostHandler(
+ "ext-wrapped",
+ "observe/health",
+ nil,
+ nil,
+ env.handler.HandleMethod("observe/health"),
+ )
result, err := wrapped(testutil.Context(t), nil)
if err != nil {
@@ -1432,6 +1915,231 @@ func TestManagerWrapHostHandlerInjectsExtensionNameForHostAPIHandler(t *testing.
}
}
+func TestNormalizeHostAPIHandlerDefaultsFillsZeroValues(t *testing.T) {
+ t.Parallel()
+
+ normalizeHostAPIHandlerDefaults(nil)
+
+ handler := &HostAPIHandler{}
+ normalizeHostAPIHandlerDefaults(handler)
+
+ if handler.now == nil {
+ t.Fatal("normalizeHostAPIHandlerDefaults() left now nil")
+ }
+ if handler.capChecker == nil {
+ t.Fatal("normalizeHostAPIHandlerDefaults() left capChecker nil")
+ }
+ if handler.bridgeIngestDedupTTL != defaultHostAPIBridgeIngestDedupTTL {
+ t.Fatalf(
+ "bridgeIngestDedupTTL = %v, want %v",
+ handler.bridgeIngestDedupTTL,
+ defaultHostAPIBridgeIngestDedupTTL,
+ )
+ }
+ if handler.bridgeCleanupInterval != defaultHostAPIBridgeCleanupInterval {
+ t.Fatalf(
+ "bridgeCleanupInterval = %v, want %v",
+ handler.bridgeCleanupInterval,
+ defaultHostAPIBridgeCleanupInterval,
+ )
+ }
+ if handler.bridgeLocks == nil {
+ t.Fatal("normalizeHostAPIHandlerDefaults() left bridgeLocks nil")
+ }
+}
+
+func TestHostAPIContextHelpersCloneBridgeAndResourceSession(t *testing.T) {
+ t.Parallel()
+
+ baseCtx := context.Background()
+ if got := withHostAPIBridgeRuntime(baseCtx, nil); got != baseCtx {
+ t.Fatalf("withHostAPIBridgeRuntime(background, nil) = %#v, want background context", got)
+ }
+ if got := withHostAPIResourceSession(baseCtx, nil); got != baseCtx {
+ t.Fatalf("withHostAPIResourceSession(background, nil) = %#v, want background context", got)
+ }
+ if _, ok := hostAPIResourceSessionFromContext(baseCtx); ok {
+ t.Fatal("hostAPIResourceSessionFromContext(background) = ok, want false")
+ }
+ if runtime := hostAPIBridgeRuntimeFromContext(baseCtx); runtime != nil {
+ t.Fatalf("hostAPIBridgeRuntimeFromContext(background) = %#v, want nil", runtime)
+ }
+
+ runtime := &subprocess.InitializeBridgeRuntime{
+ ManagedInstances: []subprocess.InitializeBridgeManagedInstance{
+ {
+ Instance: bridgepkg.BridgeInstance{
+ ID: "brg-1",
+ ExtensionName: "ext-runtime",
+ },
+ },
+ },
+ }
+ session := &hostAPIResourceSession{
+ Actor: resources.MutationActor{
+ Kind: resources.MutationActorKindExtension,
+ ID: "ext-runtime",
+ SessionNonce: "nonce-1",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("extension"),
+ ID: "ext-runtime",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ GrantedKinds: []resources.ResourceKind{"tool.definition"},
+ GrantedScopes: []resources.ResourceScopeKind{resources.ResourceScopeKindGlobal},
+ },
+ }
+
+ ctx := withHostAPIBridgeRuntime(withHostAPIResourceSession(baseCtx, session), runtime)
+
+ session.Actor.GrantedKinds[0] = "tool.call"
+ runtime.ManagedInstances[0].Instance.ID = "mutated"
+
+ storedSession, ok := hostAPIResourceSessionFromContext(ctx)
+ if !ok {
+ t.Fatal("hostAPIResourceSessionFromContext(ctx) = false, want true")
+ }
+ if got, want := storedSession.Actor.GrantedKinds, []resources.ResourceKind{
+ "tool.definition",
+ }; !slices.Equal(
+ got,
+ want,
+ ) {
+ t.Fatalf("storedSession.Actor.GrantedKinds = %#v, want %#v", got, want)
+ }
+ storedSession.Actor.GrantedKinds[0] = "tool.call"
+ reloadedSession, ok := hostAPIResourceSessionFromContext(ctx)
+ if !ok {
+ t.Fatal("hostAPIResourceSessionFromContext(ctx) after mutation = false, want true")
+ }
+ if got, want := reloadedSession.Actor.GrantedKinds, []resources.ResourceKind{
+ "tool.definition",
+ }; !slices.Equal(
+ got,
+ want,
+ ) {
+ t.Fatalf("reloadedSession.Actor.GrantedKinds = %#v, want %#v", got, want)
+ }
+
+ storedRuntime := hostAPIBridgeRuntimeFromContext(ctx)
+ if storedRuntime == nil {
+ t.Fatal("hostAPIBridgeRuntimeFromContext(ctx) = nil, want runtime")
+ }
+ if got, want := storedRuntime.ManagedInstances[0].Instance.ID, "brg-1"; got != want {
+ t.Fatalf("storedRuntime.ManagedInstances[0].Instance.ID = %q, want %q", got, want)
+ }
+}
+
+func TestNormalizeHostAPIRPCErrorMapsResourceStatuses(t *testing.T) {
+ t.Parallel()
+
+ sameRPC := subprocess.NewRPCError(499, "unchanged", map[string]string{"error": "keep"})
+ sameErr := errors.New("boom")
+
+ tests := []struct {
+ name string
+ method string
+ err error
+ wantCode int
+ wantMessage string
+ wantSame bool
+ }{
+ {name: "nil", method: "resources/list", err: nil},
+ {name: "non resource", method: "observe/health", err: sameErr, wantSame: true},
+ {name: "rpc passthrough", method: "resources/list", err: sameRPC, wantSame: true},
+ {
+ name: "rate limited",
+ method: "resources/list",
+ err: subprocess.NewRPCError(
+ HostAPIRateLimitedCode,
+ "slow down",
+ map[string]string{"error": "slow"},
+ ),
+ wantCode: 429,
+ wantMessage: "Rate limited",
+ },
+ {
+ name: "forbidden",
+ method: "resources/get",
+ err: resources.ErrPermissionDenied,
+ wantCode: 403,
+ wantMessage: "Forbidden",
+ },
+ {
+ name: "conflict",
+ method: "resources/snapshot",
+ err: resources.ErrSessionNotActive,
+ wantCode: 409,
+ wantMessage: "Conflict",
+ },
+ {
+ name: "payload too large",
+ method: "resources/snapshot",
+ err: resources.ErrPayloadTooLarge,
+ wantCode: 413,
+ wantMessage: "Payload too large",
+ },
+ {
+ name: "not found",
+ method: "resources/get",
+ err: resources.ErrNotFound,
+ wantCode: HostAPINotFoundCode,
+ wantMessage: "Not found",
+ },
+ {
+ name: "invalid params",
+ method: "resources/list",
+ err: resources.ErrValidation,
+ wantCode: HostAPIInvalidParamsCode,
+ wantMessage: "Invalid params",
+ },
+ {name: "default passthrough", method: "resources/list", err: sameErr, wantSame: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeHostAPIRPCError(tt.method, tt.err)
+ if tt.err == nil {
+ if got != nil {
+ t.Fatalf("normalizeHostAPIRPCError() = %v, want nil", got)
+ }
+ return
+ }
+ if tt.wantSame {
+ if got != tt.err {
+ t.Fatalf("normalizeHostAPIRPCError() = %#v, want original %#v", got, tt.err)
+ }
+ return
+ }
+
+ var rpcErr *subprocess.RPCError
+ if !errors.As(got, &rpcErr) {
+ t.Fatalf("normalizeHostAPIRPCError() type = %T, want *subprocess.RPCError", got)
+ }
+ if rpcErr.Code != tt.wantCode {
+ t.Fatalf("rpcErr.Code = %d, want %d", rpcErr.Code, tt.wantCode)
+ }
+ if rpcErr.Message != tt.wantMessage {
+ t.Fatalf("rpcErr.Message = %q, want %q", rpcErr.Message, tt.wantMessage)
+ }
+ })
+ }
+}
+
+func TestRPCCapabilityDeniedUsesHTTPStatusForResourceMethods(t *testing.T) {
+ t.Parallel()
+
+ resourceErr := rpcCapabilityDenied(newCapabilityDeniedError("resources/get", []string{"resource.read"}, nil))
+ assertRPCErrorCode(t, resourceErr, 403)
+ data := decodeRPCData(t, resourceErr)
+ if got := data["method"]; got != "resources/get" {
+ t.Fatalf("rpc data method = %#v, want resources/get", got)
+ }
+
+ observeErr := rpcCapabilityDenied(newCapabilityDeniedError("observe/health", []string{"observe.read"}, nil))
+ assertRPCErrorCode(t, observeErr, CapabilityDeniedCode)
+}
+
func TestHostAPIHandlerAutomationTriggerFireRejectsNonExtensionEvent(t *testing.T) {
t.Parallel()
@@ -3037,6 +3745,7 @@ type hostAPITestEnv struct {
skills *skillspkg.Registry
workspaces *hostAPIFakeWorkspaceResolver
driver *hostAPIFakeDriver
+ resources *resources.Kernel
checker *CapabilityChecker
handler *HostAPIHandler
}
@@ -3062,6 +3771,16 @@ func mustExtensionTaskActorContext(t testing.TB, extensionName string) taskpkg.A
return actor
}
+func mustLocalEnvironmentRegistry(t testing.TB) *environment.Registry {
+ t.Helper()
+
+ registry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
+ return registry
+}
+
func (e *hostAPITestTaskSessionExecutor) StartTaskSession(
ctx context.Context,
spec *taskpkg.StartTaskSession,
@@ -3240,6 +3959,28 @@ Review the workspace changes carefully.
t.Fatalf("registry.InsertWorkspace() error = %v", err)
}
bridgeRegistry := bridgepkg.NewRegistry(registry, bridgepkg.WithNow(func() time.Time { return env.currentTime() }))
+ resourceKernel, err := resources.NewKernel(
+ registry.DB(),
+ resources.WithNow(func() time.Time { return env.currentTime() }),
+ )
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+ resourceCodecs := resources.NewCodecRegistry()
+ toolCodec, err := toolspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("toolspkg.NewResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(resourceCodecs, toolCodec); err != nil {
+ t.Fatalf("resources.RegisterCodec(tool) error = %v", err)
+ }
+ mcpCodec, err := aghconfig.NewMCPServerResourceCodec()
+ if err != nil {
+ t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err)
+ }
+ if err := resources.RegisterCodec(resourceCodecs, mcpCodec); err != nil {
+ t.Fatalf("resources.RegisterCodec(mcp) error = %v", err)
+ }
observer, err := observepkg.New(testutil.Context(t),
observepkg.WithRegistry(registry),
@@ -3265,6 +4006,7 @@ Review the workspace changes carefully.
session.WithNotifier(observer),
session.WithWorkspaceResolver(workspaces),
session.WithStore(storeSessionDB),
+ session.WithEnvironmentRegistry(mustLocalEnvironmentRegistry(t)),
session.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
session.WithNow(func() time.Time { return env.currentTime() }),
session.WithSessionIDGenerator(sequentialSessionIDGenerator("sess")),
@@ -3336,6 +4078,8 @@ Review the workspace changes carefully.
WithHostAPIWorkspaceResolver(workspaces),
WithHostAPIBridgeRegistry(bridgeRegistry),
WithHostAPIBridgeDedupStore(registry),
+ WithHostAPIResourceStore(resourceKernel),
+ WithHostAPIResourceCodecRegistry(resourceCodecs),
WithHostAPINow(func() time.Time { return env.currentTime() }),
WithHostAPIBridgeIngressConfig(15*time.Minute, time.Minute),
WithHostAPIRateLimit(1000, 1000),
@@ -3353,6 +4097,7 @@ Review the workspace changes carefully.
env.skills = skillsRegistry
env.workspaces = workspaces
env.driver = driver
+ env.resources = resourceKernel
env.checker = checker
env.handler = handler
return env
@@ -3365,12 +4110,47 @@ func (e *hostAPITestEnv) grant(extName string, actions []string, security []stri
})
}
+func (e *hostAPITestEnv) grantWithResources(
+ t testing.TB,
+ extName string,
+ actions []string,
+ security []string,
+ resourceFamilies []string,
+ maxScope resources.ResourceScopeKind,
+) {
+ t.Helper()
+
+ _, err := e.checker.RegisterForSession(extName, SourceUser, &Manifest{
+ Actions: ActionsConfig{Requires: append([]string(nil), actions...)},
+ Security: SecurityConfig{Capabilities: append([]string(nil), security...)},
+ Resources: ResourcesConfig{
+ Publish: ResourceGrantRequest{
+ Families: append([]string(nil), resourceFamilies...),
+ MaxScope: maxScope,
+ },
+ },
+ }, resources.ResourceScopeKindGlobal)
+ if err != nil {
+ t.Fatalf("RegisterForSession(%q) error = %v", extName, err)
+ }
+}
+
func (e *hostAPITestEnv) currentTime() time.Time {
e.nowMu.RLock()
defer e.nowMu.RUnlock()
return e.now
}
+func hostAPITestToolSpec(name string, description string, source string) map[string]any {
+ return map[string]any{
+ "name": name,
+ "description": description,
+ "input_schema": map[string]any{"type": "object"},
+ "read_only": true,
+ "source": source,
+ }
+}
+
func (e *hostAPITestEnv) advanceTime(delta time.Duration) time.Time {
e.nowMu.Lock()
defer e.nowMu.Unlock()
@@ -3404,6 +4184,59 @@ func (e *hostAPITestEnv) callWithContext(
return e.handler.Handle(ctx, extName, method, eRaw)
}
+func (e *hostAPITestEnv) resourceContext(t testing.TB, extName string, sessionNonce string) context.Context {
+ t.Helper()
+
+ grant := e.checker.Grant(extName)
+ return withHostAPIResourceSession(testutil.Context(t), &hostAPIResourceSession{
+ Actor: resources.MutationActor{
+ Kind: resources.MutationActorKindExtension,
+ ID: extName,
+ SessionNonce: sessionNonce,
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("extension"),
+ ID: extName,
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ GrantedKinds: append([]resources.ResourceKind(nil), grant.ResourceKinds...),
+ GrantedScopes: append([]resources.ResourceScopeKind(nil), grant.ResourceScopes...),
+ },
+ })
+}
+
+func (e *hostAPITestEnv) activateResourceSession(t testing.TB, extName string, sessionNonce string) {
+ t.Helper()
+
+ if e.resources == nil {
+ t.Fatal("resource kernel is not configured")
+ }
+ if err := e.resources.ActivateSourceSession(testutil.Context(t), resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "host-api-tests",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "host-api-tests",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }, resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("extension"),
+ ID: extName,
+ }, sessionNonce); err != nil {
+ t.Fatalf("ActivateSourceSession(%q) error = %v", extName, err)
+ }
+}
+
+func (e *hostAPITestEnv) callResource(
+ t testing.TB,
+ extName string,
+ sessionNonce string,
+ method string,
+ params any,
+) (any, error) {
+ t.Helper()
+ return e.callWithContext(e.resourceContext(t, extName, sessionNonce), t, extName, method, params)
+}
+
func (e *hostAPITestEnv) bridgeContext(t testing.TB, instance *bridgepkg.BridgeInstance) context.Context {
t.Helper()
@@ -3518,6 +4351,7 @@ func (e *hostAPITestEnv) useSessionsWithoutObserver(t *testing.T) {
session.WithDriver(e.driver),
session.WithWorkspaceResolver(e.workspaces),
session.WithStore(storeSessionDB),
+ session.WithEnvironmentRegistry(mustLocalEnvironmentRegistry(t)),
session.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
session.WithNow(func() time.Time { return e.currentTime() }),
session.WithSessionIDGenerator(sequentialSessionIDGenerator("sess")),
@@ -3551,6 +4385,7 @@ func (e *hostAPITestEnv) useSessionsWithoutObserver(t *testing.T) {
WithHostAPIWorkspaceResolver(e.workspaces),
WithHostAPIBridgeRegistry(e.bridges),
WithHostAPIBridgeDedupStore(e.registry),
+ WithHostAPIResourceStore(e.resources),
WithHostAPINow(func() time.Time { return e.currentTime() }),
WithHostAPIBridgeIngressConfig(15*time.Minute, time.Minute),
WithHostAPIRateLimit(1000, 1000),
@@ -3740,6 +4575,7 @@ func (d *hostAPIFakeDriver) Start(_ context.Context, opts acp.StartOpts) (*sessi
AgentName: opts.AgentName,
Command: opts.Command,
Cwd: opts.Cwd,
+ ToolHost: opts.ToolHost,
SessionID: fmt.Sprintf("acp-%d", seq),
StartedAt: d.now.Add(time.Duration(seq) * time.Millisecond),
Done: procState.ch,
@@ -3843,6 +4679,16 @@ func mustMarshalRawMessage(t testing.TB, params any) json.RawMessage {
return raw
}
+func mustMarshalJSON(t testing.TB, value any) []byte {
+ t.Helper()
+
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("json.Marshal() error = %v", err)
+ }
+ return encoded
+}
+
func decodeResult(t testing.TB, result any, target any) {
t.Helper()
diff --git a/internal/extension/linear_provider_integration_test.go b/internal/extension/linear_provider_integration_test.go
index 56e546979..87641a6b1 100644
--- a/internal/extension/linear_provider_integration_test.go
+++ b/internal/extension/linear_provider_integration_test.go
@@ -126,8 +126,9 @@ func TestLinearProviderSharedWebhookIngressAndDeliveryConformance(t *testing.T)
waitForLinearReadyStates(t, harness, []string{"brg-linear-comments", "brg-linear-agent"})
webhookURL := fmt.Sprintf("http://%s/linear", listenAddr)
- postLinearProviderWebhook(t, webhookURL, linearCommentWebhookBody(startTime))
- postLinearProviderWebhook(t, webhookURL, linearAgentSessionWebhookBody(startTime))
+ webhookTime := time.Now().UTC()
+ postLinearProviderWebhook(t, webhookURL, linearCommentWebhookBody(webhookTime))
+ postLinearProviderWebhook(t, webhookURL, linearAgentSessionWebhookBody(webhookTime))
ingests := harness.WaitForIngests(t, 10*time.Second, func(records []extensiontest.IngestRecord) bool {
if len(records) < 2 {
diff --git a/internal/extension/manager.go b/internal/extension/manager.go
index 6fddadb64..123768e1c 100644
--- a/internal/extension/manager.go
+++ b/internal/extension/manager.go
@@ -2,6 +2,8 @@ package extensionpkg
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -18,6 +20,7 @@ import (
aghconfig "github.com/pedronauck/agh/internal/config"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/subprocess"
"github.com/pedronauck/agh/internal/version"
@@ -92,11 +95,6 @@ type processHandle interface {
type processLauncher func(context.Context, subprocess.LaunchConfig) (processHandle, error)
-type skillRegistry interface {
- RegisterExternal(owner string, skills []*skillspkg.Skill) error
- RemoveExternal(owner string)
-}
-
// BridgeRuntimeResolver resolves one provider-scoped bridge launch payload
// for a bridge-capable extension session.
type BridgeRuntimeResolver interface {
@@ -147,36 +145,39 @@ type ExtensionStatus struct {
// Extension is the manager-visible snapshot for one installed extension.
type Extension struct {
- Info ExtensionInfo
- Manifest *Manifest
- RootDir string
- Hooks []hookspkg.HookDecl
- Agents []aghconfig.AgentDef
- Bundles []BundleSpec
- MCPServers []aghconfig.MCPServer
- Skills []*skillspkg.Skill
- GrantedActions []string
- GrantedSecurity []string
- InitializeResult *subprocess.InitializeResponse
- Status ExtensionStatus
+ Info ExtensionInfo
+ Manifest *Manifest
+ RootDir string
+ Hooks []hookspkg.HookDecl
+ Agents []aghconfig.AgentDef
+ Bundles []BundleSpec
+ Skills []*skillspkg.Skill
+ GrantedActions []string
+ GrantedSecurity []string
+ GrantedResourceKinds []resources.ResourceKind
+ GrantedResourceScopes []resources.ResourceScopeKind
+ InitializeResult *subprocess.InitializeResponse
+ Status ExtensionStatus
}
type managedExtension struct {
- info ExtensionInfo
- rootDir string
- manifest *Manifest
- hooks []hookspkg.HookDecl
- agents []aghconfig.AgentDef
- bundles []BundleSpec
- mcpServers []aghconfig.MCPServer
- skills []*skillspkg.Skill
- grantedActions []string
- grantedSecurity []string
- initialize *subprocess.InitializeResponse
- process processHandle
- runtime subprocess.InitializeRuntime
- healthInterval time.Duration
- generation int64
+ info ExtensionInfo
+ rootDir string
+ manifest *Manifest
+ hooks []hookspkg.HookDecl
+ agents []aghconfig.AgentDef
+ bundles []BundleSpec
+ skills []*skillspkg.Skill
+ grantedActions []string
+ grantedSecurity []string
+ grantedResourceKinds []resources.ResourceKind
+ grantedResourceScopes []resources.ResourceScopeKind
+ initialize *subprocess.InitializeResponse
+ process processHandle
+ runtime subprocess.InitializeRuntime
+ healthInterval time.Duration
+ generation int64
+ sessionNonce string
phase ExtensionPhase
registered bool
@@ -199,7 +200,7 @@ type Manager struct {
capChecker *CapabilityChecker
bridgeRuntimeResolver BridgeRuntimeResolver
bridgeTelemetrySink BridgeTelemetrySink
- skillsRegistry skillRegistry
+ sourceSessions resources.SourceSessionManager
logger *slog.Logger
now func() time.Time
getenv func(string) string
@@ -251,10 +252,11 @@ func WithBridgeTelemetrySink(sink BridgeTelemetrySink) Option {
}
}
-// WithSkillsRegistry injects the skills registry used for extension skill registration.
-func WithSkillsRegistry(registry skillRegistry) Option {
- return func(manager *Manager) {
- manager.skillsRegistry = registry
+// WithSourceSessionManager injects the resource source-session manager used to
+// activate extension nonces for snapshot publication.
+func WithSourceSessionManager(manager resources.SourceSessionManager) Option {
+ return func(mgr *Manager) {
+ mgr.sourceSessions = manager
}
}
@@ -780,33 +782,6 @@ func (m *Manager) AgentDefinitions() []aghconfig.AgentDef {
return agents
}
-// MCPServers returns the currently registered extension MCP server declarations.
-func (m *Manager) MCPServers() []aghconfig.MCPServer {
- if m == nil {
- return nil
- }
-
- m.mu.RLock()
- defer m.mu.RUnlock()
-
- var servers []aghconfig.MCPServer
- names := make([]string, 0, len(m.extensions))
- for name := range m.extensions {
- names = append(names, name)
- }
- slices.Sort(names)
- for _, name := range names {
- ext := m.extensions[name]
- if !ext.registered {
- continue
- }
- for _, server := range ext.mcpServers {
- servers = append(servers, cloneMCPServer(server))
- }
- }
- return servers
-}
-
func (m *Manager) startOne(ctx context.Context, ext *managedExtension) error {
if err := m.discoverExtension(ext); err != nil {
return err
@@ -885,9 +860,20 @@ func (m *Manager) validateExtension(ext *managedExtension) error {
return phaseError(ext.info.Name, ExtensionPhaseValidate, err)
}
- ext.grantedActions = effectiveActionGrants(ext.info.Source, ext.manifest.Actions.Requires)
- ext.grantedSecurity = effectiveSecurityGrants(ext.info.Source, ext.manifest.Security.Capabilities)
- m.capChecker.Register(ext.info.Name, ext.info.Source, ext.manifest)
+ grant, err := m.capChecker.RegisterForSession(
+ ext.info.Name,
+ ext.info.Source,
+ ext.manifest,
+ resources.ResourceScopeKindGlobal,
+ )
+ if err != nil {
+ m.setFailure(ext, ExtensionPhaseValidate, err)
+ return phaseError(ext.info.Name, ExtensionPhaseValidate, err)
+ }
+ ext.grantedActions = grant.Actions
+ ext.grantedSecurity = grant.Security
+ ext.grantedResourceKinds = grant.ResourceKinds
+ ext.grantedResourceScopes = grant.ResourceScopes
ext.phase = ExtensionPhaseValidate
return nil
}
@@ -918,30 +904,11 @@ func (m *Manager) registerExtension(ctx context.Context, ext *managedExtension)
m.setFailure(ext, ExtensionPhaseRegister, err)
return phaseError(ext.info.Name, ExtensionPhaseRegister, err)
}
- mcpServers, err := m.loadMCPResources(ext)
- if err != nil {
- m.setFailure(ext, ExtensionPhaseRegister, err)
- return phaseError(ext.info.Name, ExtensionPhaseRegister, err)
- }
-
- if len(skills) > 0 {
- if m.skillsRegistry == nil {
- err := errors.New("skills registry is required for extension skill resources")
- m.setFailure(ext, ExtensionPhaseRegister, err)
- return phaseError(ext.info.Name, ExtensionPhaseRegister, err)
- }
- if err := m.skillsRegistry.RegisterExternal(ext.info.Name, skills); err != nil {
- m.setFailure(ext, ExtensionPhaseRegister, err)
- return phaseError(ext.info.Name, ExtensionPhaseRegister, err)
- }
- }
-
m.mu.Lock()
ext.skills = skills
ext.agents = agents
ext.hooks = hooks
ext.bundles = bundles
- ext.mcpServers = mcpServers
ext.registered = true
ext.phase = ExtensionPhaseRegister
m.mu.Unlock()
@@ -1011,7 +978,6 @@ func (m *Manager) activateExtension(ext *managedExtension) {
skillCount := len(ext.skills)
agentCount := len(ext.agents)
hookCount := len(ext.hooks)
- mcpServerCount := len(ext.mcpServers)
m.mu.Unlock()
m.logger.Info(
@@ -1022,7 +988,6 @@ func (m *Manager) activateExtension(ext *managedExtension) {
"skill_count", skillCount,
"agent_count", agentCount,
"hook_count", hookCount,
- "mcp_server_count", mcpServerCount,
)
}
@@ -1174,35 +1139,118 @@ func (m *Manager) launchRuntime(
)
}
+ resourceSession, err := m.prepareExtensionResourceSession(ctx, ext)
+ if err != nil {
+ return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, m.cleanupLaunchedProcess(
+ process,
+ err,
+ )
+ }
+ if err := m.registerRuntimeHostMethods(process, ext, runtime, resourceSession); err != nil {
+ return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, m.cleanupLaunchedProcess(
+ process,
+ err,
+ )
+ }
+
+ response, err := m.initializeRuntimeProcess(ctx, process, ext, runtime, resourceSession)
+ if err != nil {
+ return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, m.cleanupLaunchedProcess(
+ process,
+ err,
+ )
+ }
+ if err := validateSupportedHookEvents(response.SupportedHookEvents); err != nil {
+ return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, m.cleanupLaunchedProcess(
+ process,
+ err,
+ )
+ }
+
+ ext.sessionNonce = resourceSession.Actor.SessionNonce
+
+ return process, response, runtime, healthInterval, nil
+}
+
+func (m *Manager) cleanupLaunchedProcess(process processHandle, err error) error {
+ if process == nil {
+ return err
+ }
+ if shutdownErr := shutdownProcessWithTimeout(process, m.defaultShutdownTimeout); shutdownErr != nil {
+ return errors.Join(err, shutdownErr)
+ }
+ return err
+}
+
+func (m *Manager) prepareExtensionResourceSession(
+ ctx context.Context,
+ ext *managedExtension,
+) (*hostAPIResourceSession, error) {
+ resourceSession, err := m.newHostAPIResourceSession(ext)
+ if err != nil {
+ return nil, err
+ }
+ if err := m.activateExtensionSourceSession(ctx, resourceSession.Actor); err != nil {
+ return nil, err
+ }
+ return resourceSession, nil
+}
+
+func (m *Manager) registerRuntimeHostMethods(
+ process processHandle,
+ ext *managedExtension,
+ runtime subprocess.InitializeRuntime,
+ resourceSession *hostAPIResourceSession,
+) error {
for method, handler := range m.hostMethods {
if err := process.HandleMethod(
method,
- m.wrapHostHandler(ext.info.Name, method, runtime.Bridge, handler),
+ m.wrapHostHandler(ext.info.Name, method, runtime.Bridge, resourceSession, handler),
); err != nil {
- if shutdownErr := shutdownProcessWithTimeout(process, m.defaultShutdownTimeout); shutdownErr != nil {
- err = errors.Join(err, shutdownErr)
- }
- return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, fmt.Errorf(
- "register host method %q: %w",
- method,
- err,
- )
+ return fmt.Errorf("register host method %q: %w", method, err)
}
}
+ return nil
+}
+
+func (m *Manager) initializeRuntimeProcess(
+ ctx context.Context,
+ process processHandle,
+ ext *managedExtension,
+ runtime subprocess.InitializeRuntime,
+ resourceSession *hostAPIResourceSession,
+) (subprocess.InitializeResponse, error) {
+ initCtx, cancel := context.WithTimeout(ctx, m.initializeTimeout)
+ defer cancel()
- request := subprocess.InitializeRequest{
+ response, err := process.Initialize(initCtx, m.initializeRuntimeRequest(ext, runtime, resourceSession))
+ if err != nil {
+ return subprocess.InitializeResponse{}, fmt.Errorf("initialize subprocess: %w", err)
+ }
+ return response, nil
+}
+
+func (m *Manager) initializeRuntimeRequest(
+ ext *managedExtension,
+ runtime subprocess.InitializeRuntime,
+ resourceSession *hostAPIResourceSession,
+) subprocess.InitializeRequest {
+ return subprocess.InitializeRequest{
ProtocolVersion: m.protocolVersion,
SupportedProtocolVersion: slices.Clone(m.supportedProtocolVersions),
AGHVersion: version.Current().Version,
+ SessionNonce: resourceSession.Actor.SessionNonce,
Extension: subprocess.InitializeExtension{
Name: ext.manifest.Name,
Version: ext.manifest.Version,
SourceTier: ext.info.Source.String(),
},
Capabilities: subprocess.InitializeCapabilities{
- Provides: normalizeUniqueStrings(ext.manifest.Capabilities.Provides),
- GrantedActions: hostAPIMethodsFromStrings(ext.grantedActions),
- GrantedSecurity: normalizeUniqueStrings(ext.grantedSecurity),
+ Provides: normalizeUniqueStrings(ext.manifest.Capabilities.Provides),
+ GrantedActions: hostAPIMethodsFromStrings(ext.grantedActions),
+ GrantedSecurity: normalizeUniqueStrings(ext.grantedSecurity),
+ GrantedResourceKinds: append([]resources.ResourceKind{}, ext.grantedResourceKinds...),
+ GrantedResourceScopes: append([]resources.ResourceScopeKind{}, ext.grantedResourceScopes...),
},
Methods: subprocess.InitializeMethods{
DaemonRequests: daemonRequestMethods(),
@@ -1210,28 +1258,6 @@ func (m *Manager) launchRuntime(
},
Runtime: runtime,
}
-
- initCtx, cancel := context.WithTimeout(ctx, m.initializeTimeout)
- defer cancel()
-
- response, err := process.Initialize(initCtx, request)
- if err != nil {
- if shutdownErr := shutdownProcessWithTimeout(process, m.defaultShutdownTimeout); shutdownErr != nil {
- err = errors.Join(err, shutdownErr)
- }
- return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, fmt.Errorf(
- "initialize subprocess: %w",
- err,
- )
- }
- if err := validateSupportedHookEvents(response.SupportedHookEvents); err != nil {
- if shutdownErr := shutdownProcessWithTimeout(process, m.defaultShutdownTimeout); shutdownErr != nil {
- err = errors.Join(err, shutdownErr)
- }
- return nil, subprocess.InitializeResponse{}, subprocess.InitializeRuntime{}, 0, err
- }
-
- return process, response, runtime, healthInterval, nil
}
func (m *Manager) launchConfigFor(
@@ -1285,6 +1311,7 @@ func (m *Manager) wrapHostHandler(
extName string,
method string,
bridgeRuntime *subprocess.InitializeBridgeRuntime,
+ resourceSession *hostAPIResourceSession,
handler subprocess.HandlerFunc,
) subprocess.HandlerFunc {
return func(ctx context.Context, params json.RawMessage) (any, error) {
@@ -1296,10 +1323,75 @@ func (m *Manager) wrapHostHandler(
if bridgeRuntime != nil {
hostCtx = withHostAPIBridgeRuntime(hostCtx, bridgeRuntime)
}
+ if resourceSession != nil {
+ hostCtx = withHostAPIResourceSession(hostCtx, resourceSession)
+ }
return handler(hostCtx, params)
}
}
+func (m *Manager) newHostAPIResourceSession(ext *managedExtension) (*hostAPIResourceSession, error) {
+ if ext == nil {
+ return nil, errors.New("extension: managed extension is required")
+ }
+
+ sessionNonce, err := newExtensionSessionNonce()
+ if err != nil {
+ return nil, fmt.Errorf("extension: generate session nonce for %q: %w", ext.info.Name, err)
+ }
+
+ return &hostAPIResourceSession{
+ Actor: resources.MutationActor{
+ Kind: resources.MutationActorKindExtension,
+ ID: ext.info.Name,
+ SessionNonce: sessionNonce,
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("extension"),
+ ID: ext.info.Name,
+ },
+ // Workspace binding is not yet carried on extension sessions, so v1
+ // relies on granted scope kinds for narrowing and keeps the max scope
+ // ceiling global here.
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ GrantedKinds: append(
+ []resources.ResourceKind(nil),
+ ext.grantedResourceKinds...,
+ ),
+ GrantedScopes: append(
+ []resources.ResourceScopeKind(nil),
+ ext.grantedResourceScopes...,
+ ),
+ },
+ }, nil
+}
+
+func (m *Manager) activateExtensionSourceSession(ctx context.Context, actor resources.MutationActor) error {
+ if m == nil || m.sourceSessions == nil {
+ return nil
+ }
+
+ if err := m.sourceSessions.ActivateSourceSession(ctx, resources.MutationActor{
+ Kind: resources.MutationActorKindDaemon,
+ ID: "extension-manager",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("daemon"),
+ ID: "extension-manager",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ }, actor.Source, actor.SessionNonce); err != nil {
+ return fmt.Errorf("extension: activate source session for %q: %w", actor.Source.ID, err)
+ }
+ return nil
+}
+
+func newExtensionSessionNonce() (string, error) {
+ var raw [16]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(raw[:]), nil
+}
+
func (m *Manager) loadSkillResources(ext *managedExtension) ([]*skillspkg.Skill, error) {
if ext.manifest == nil || len(ext.manifest.Resources.Skills) == 0 {
return nil, nil
@@ -1386,46 +1478,6 @@ func (m *Manager) loadBundleResources(ext *managedExtension) ([]BundleSpec, erro
return LoadBundleSpecs(ext.rootDir, ext.manifest)
}
-func (m *Manager) loadMCPResources(ext *managedExtension) ([]aghconfig.MCPServer, error) {
- if ext.manifest == nil || len(ext.manifest.Resources.MCPServers) == 0 {
- return nil, nil
- }
-
- names := make([]string, 0, len(ext.manifest.Resources.MCPServers))
- for name := range ext.manifest.Resources.MCPServers {
- names = append(names, name)
- }
- slices.Sort(names)
-
- servers := make([]aghconfig.MCPServer, 0, len(names))
- for _, name := range names {
- decl := ext.manifest.Resources.MCPServers[name]
- command, err := m.resolveCommand(ext.rootDir, decl.Command)
- if err != nil {
- return nil, err
- }
- args, err := m.resolveStringSlice(ext.rootDir, decl.Args)
- if err != nil {
- return nil, err
- }
- env, err := m.resolveStringMap(ext.rootDir, decl.Env)
- if err != nil {
- return nil, err
- }
- server := aghconfig.MCPServer{
- Name: strings.TrimSpace(name),
- Command: command,
- Args: args,
- Env: env,
- }
- if err := server.Validate("extension.resources.mcp_servers[" + name + "]"); err != nil {
- return nil, err
- }
- servers = append(servers, server)
- }
- return servers, nil
-}
-
func (m *Manager) hookConfigToDecl(ext *managedExtension, cfg HookConfig) (hookspkg.HookDecl, error) {
command := strings.TrimSpace(cfg.Command)
args := slices.Clone(cfg.Args)
@@ -1510,52 +1562,15 @@ func (m *Manager) hookConfigToDecl(ext *managedExtension, cfg HookConfig) (hooks
}
func (m *Manager) resolveCommand(rootDir string, value string) (string, error) {
- resolved, err := m.resolveString(rootDir, value)
- if err != nil {
- return "", err
- }
- if resolved == "" {
- return "", nil
- }
- if filepath.IsAbs(resolved) {
- return filepath.Clean(resolved), nil
- }
- if strings.ContainsRune(resolved, filepath.Separator) || strings.HasPrefix(resolved, ".") {
- return resolvePathWithinRoot(rootDir, resolved)
- }
- return resolved, nil
+ return resolveManifestCommand(rootDir, value, m.getenv)
}
func (m *Manager) resolveStringSlice(rootDir string, values []string) ([]string, error) {
- if len(values) == 0 {
- return nil, nil
- }
-
- resolved := make([]string, 0, len(values))
- for _, value := range values {
- item, err := m.resolveString(rootDir, value)
- if err != nil {
- return nil, err
- }
- resolved = append(resolved, item)
- }
- return resolved, nil
+ return resolveManifestStringSlice(rootDir, values, m.getenv)
}
func (m *Manager) resolveStringMap(rootDir string, env map[string]string) (map[string]string, error) {
- if len(env) == 0 {
- return nil, nil
- }
-
- resolved := make(map[string]string, len(env))
- for key, value := range env {
- item, err := m.resolveString(rootDir, value)
- if err != nil {
- return nil, err
- }
- resolved[key] = item
- }
- return resolved, nil
+ return resolveManifestStringMap(rootDir, env, m.getenv)
}
func (m *Manager) resolveEnvMap(rootDir string, env map[string]string) ([]string, error) {
@@ -1595,26 +1610,7 @@ func (m *Manager) resolveEnvMap(rootDir string, env map[string]string) ([]string
}
func (m *Manager) resolveString(rootDir string, value string) (string, error) {
- resolved := strings.TrimSpace(value)
- if resolved == "" {
- return "", nil
- }
-
- resolved = strings.ReplaceAll(resolved, "{{config_dir}}", rootDir)
- for {
- start := strings.Index(resolved, "{{env:")
- if start < 0 {
- break
- }
- end := strings.Index(resolved[start:], "}}")
- if end < 0 {
- return "", fmt.Errorf("invalid env template %q", value)
- }
- end += start
- key := strings.TrimSpace(strings.TrimPrefix(resolved[start:end], "{{env:"))
- resolved = resolved[:start] + m.getenv(key) + resolved[end+2:]
- }
- return resolved, nil
+ return resolveManifestString(rootDir, value, m.getenv)
}
func (m *Manager) setFailure(ext *managedExtension, phase ExtensionPhase, err error) {
@@ -1738,9 +1734,6 @@ func (m *Manager) unregisterResources(ext *managedExtension) {
if ext == nil {
return
}
- if len(ext.skills) > 0 && m.skillsRegistry != nil {
- m.skillsRegistry.RemoveExternal(ext.info.Name)
- }
m.capChecker.Unregister(ext.info.Name)
m.mu.Lock()
@@ -1799,11 +1792,13 @@ func (m *Manager) cloneExtension(ext *managedExtension) *Extension {
defer m.mu.RUnlock()
clone := &Extension{
- Info: cloneExtensionInfo(ext.info),
- RootDir: ext.rootDir,
- GrantedActions: slices.Clone(ext.grantedActions),
- GrantedSecurity: slices.Clone(ext.grantedSecurity),
- Status: m.statusLocked(ext),
+ Info: cloneExtensionInfo(ext.info),
+ RootDir: ext.rootDir,
+ GrantedActions: slices.Clone(ext.grantedActions),
+ GrantedSecurity: slices.Clone(ext.grantedSecurity),
+ GrantedResourceKinds: slices.Clone(ext.grantedResourceKinds),
+ GrantedResourceScopes: slices.Clone(ext.grantedResourceScopes),
+ Status: m.statusLocked(ext),
}
if ext.manifest != nil {
clone.Manifest = cloneManifest(ext.manifest)
@@ -1815,9 +1810,6 @@ func (m *Manager) cloneExtension(ext *managedExtension) *Extension {
clone.Agents = append(clone.Agents, cloneAgentDef(agent))
}
clone.Bundles = cloneBundleSpecs(ext.bundles)
- for _, server := range ext.mcpServers {
- clone.MCPServers = append(clone.MCPServers, cloneMCPServer(server))
- }
if len(ext.skills) > 0 {
clone.Skills = make([]*skillspkg.Skill, 0, len(ext.skills))
for _, skill := range ext.skills {
@@ -2009,7 +2001,9 @@ func requiresSubprocess(manifest *Manifest) bool {
if strings.TrimSpace(manifest.Subprocess.Command) != "" {
return true
}
- return len(manifest.Capabilities.Provides) > 0 || len(manifest.Actions.Requires) > 0
+ return len(manifest.Capabilities.Provides) > 0 ||
+ len(manifest.Actions.Requires) > 0 ||
+ len(manifest.Resources.Publish.Families) > 0
}
func durationOr(value Duration, fallback time.Duration) time.Duration {
diff --git a/internal/extension/manager_integration_test.go b/internal/extension/manager_integration_test.go
index d40a8ff5e..b4be16517 100644
--- a/internal/extension/manager_integration_test.go
+++ b/internal/extension/manager_integration_test.go
@@ -11,8 +11,9 @@ import (
"testing"
"time"
+ aghconfig "github.com/pedronauck/agh/internal/config"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
- skillspkg "github.com/pedronauck/agh/internal/skills"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/subprocess"
"github.com/pedronauck/agh/internal/testutil"
)
@@ -114,7 +115,6 @@ func TestManagerIntegrationResourceRegistration(t *testing.T) {
withDaemonVersion(t, "0.5.0")
env := newRegistryTestEnv(t)
- skillsRegistry := skillspkg.NewRegistry(skillspkg.RegistryConfig{})
fixture := createManagerTestExtension(t, managerTestManifest("ext-resources", managerManifestOptions{
command: helperCommand(t),
args: helperArgs(),
@@ -134,7 +134,6 @@ func TestManagerIntegrationResourceRegistration(t *testing.T) {
manager := NewManager(
env.registry,
- WithSkillsRegistry(skillsRegistry),
WithHealthCheckTimeout(20*time.Millisecond),
WithSubprocessSignalGrace(15*time.Millisecond),
)
@@ -148,12 +147,16 @@ func TestManagerIntegrationResourceRegistration(t *testing.T) {
}
})
- if skills := skillsRegistry.List(); len(skills) != 1 || skills[0].Meta.Name != "resource-skill" {
- t.Fatalf("skills registry List() = %#v, want resource-skill", skills)
- }
if agents := manager.AgentDefinitions(); len(agents) != 1 || agents[0].Name != "resource-agent" {
t.Fatalf("AgentDefinitions() = %#v, want resource-agent", agents)
}
+ loaded, err := manager.Get("ext-resources")
+ if err != nil {
+ t.Fatalf("Get(ext-resources) error = %v", err)
+ }
+ if len(loaded.Skills) != 1 || loaded.Skills[0].Meta.Name != "resource-skill" {
+ t.Fatalf("Get(ext-resources).Skills = %#v, want resource-skill extension snapshot", loaded.Skills)
+ }
if decls, err := manager.HookDeclarations(testutil.Context(t)); err != nil {
t.Fatalf("HookDeclarations() error = %v", err)
} else if len(decls) != 1 || decls[0].Name != "ext-resources-hook" {
@@ -231,6 +234,163 @@ func TestManagerIntegrationBridgeAdapterNegotiatesDeliveryRuntime(t *testing.T)
}
}
+func TestManagerIntegrationWorkspaceExtensionCannotReceiveGlobalResourceScope(t *testing.T) {
+ withDaemonVersion(t, "0.5.0")
+
+ env := newRegistryTestEnv(t)
+ fixture := createManagerTestExtension(t, managerTestManifest("ext-workspace-grants", managerManifestOptions{
+ command: helperCommand(t),
+ args: helperArgs(),
+ withEnv: helperEnv("default", ""),
+ resourceFamilies: []string{"tools"},
+ resourceMaxScope: "global",
+ }), nil)
+ installManagerFixture(t, env.registry, fixture, SourceWorkspace, true)
+
+ manager := NewManager(
+ env.registry,
+ WithHealthCheckTimeout(20*time.Millisecond),
+ WithSubprocessSignalGrace(15*time.Millisecond),
+ )
+
+ if err := manager.Start(testutil.Context(t)); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Stop(testutil.Context(t)); err != nil {
+ t.Fatalf("Stop() cleanup error = %v", err)
+ }
+ })
+
+ ext, err := manager.Get("ext-workspace-grants")
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if !slicesEqualResourceKinds(ext.GrantedResourceKinds, []resources.ResourceKind{resources.ResourceKind("tool")}) {
+ t.Fatalf("GrantedResourceKinds = %#v, want [tool]", ext.GrantedResourceKinds)
+ }
+ if !slicesEqualResourceScopes(ext.GrantedResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("GrantedResourceScopes = %#v, want [workspace]", ext.GrantedResourceScopes)
+ }
+}
+
+func TestManagerIntegrationResourceGrantsComeFromDaemonPolicy(t *testing.T) {
+ withDaemonVersion(t, "0.5.0")
+
+ env := newRegistryTestEnv(t)
+ checker := &CapabilityChecker{}
+ checker.SetResourcePolicy(aghconfig.ExtensionsResourcesConfig{
+ AllowedKinds: []resources.ResourceKind{resources.ResourceKind("tool")},
+ MaxScope: resources.ResourceScopeKindWorkspace,
+ })
+ fixture := createManagerTestExtension(t, managerTestManifest("ext-daemon-policy", managerManifestOptions{
+ command: helperCommand(t),
+ args: helperArgs(),
+ withEnv: helperEnv("default", ""),
+ resourceFamilies: []string{"tools", "mcp_servers"},
+ resourceMaxScope: "global",
+ }), nil)
+ installManagerFixture(t, env.registry, fixture, SourceUser, true)
+
+ manager := NewManager(
+ env.registry,
+ WithCapabilityChecker(checker),
+ WithHealthCheckTimeout(20*time.Millisecond),
+ WithSubprocessSignalGrace(15*time.Millisecond),
+ )
+
+ if err := manager.Start(testutil.Context(t)); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Stop(testutil.Context(t)); err != nil {
+ t.Fatalf("Stop() cleanup error = %v", err)
+ }
+ })
+
+ ext, err := manager.Get("ext-daemon-policy")
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if !slicesEqualResourceKinds(ext.GrantedResourceKinds, []resources.ResourceKind{resources.ResourceKind("tool")}) {
+ t.Fatalf("GrantedResourceKinds = %#v, want [tool]", ext.GrantedResourceKinds)
+ }
+ if !slicesEqualResourceScopes(ext.GrantedResourceScopes, []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}) {
+ t.Fatalf("GrantedResourceScopes = %#v, want [workspace]", ext.GrantedResourceScopes)
+ }
+}
+
+func TestManagerIntegrationInitializeIncludesSessionNonceAndResourceGrants(t *testing.T) {
+ withDaemonVersion(t, "0.5.0")
+
+ env := newRegistryTestEnv(t)
+ markerPath := filepath.Join(t.TempDir(), "resource-init.jsonl")
+ checker := &CapabilityChecker{}
+ checker.SetResourcePolicy(aghconfig.ExtensionsResourcesConfig{
+ AllowedKinds: []resources.ResourceKind{resources.ResourceKind("tool")},
+ MaxScope: resources.ResourceScopeKindWorkspace,
+ })
+ fixture := createManagerTestExtension(t, managerTestManifest("ext-resource-init", managerManifestOptions{
+ command: helperCommand(t),
+ args: helperArgs(),
+ withEnv: helperEnv("record_initialize", markerPath),
+ resourceFamilies: []string{"tools", "mcp_servers"},
+ resourceMaxScope: "global",
+ }), nil)
+ installManagerFixture(t, env.registry, fixture, SourceUser, true)
+
+ resourceKernel, err := resources.NewKernel(env.registry.DB())
+ if err != nil {
+ t.Fatalf("resources.NewKernel() error = %v", err)
+ }
+
+ manager := NewManager(
+ env.registry,
+ WithCapabilityChecker(checker),
+ WithSourceSessionManager(resourceKernel),
+ WithHealthCheckTimeout(20*time.Millisecond),
+ WithSubprocessSignalGrace(15*time.Millisecond),
+ )
+
+ if err := manager.Start(testutil.Context(t)); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := manager.Stop(testutil.Context(t)); err != nil {
+ t.Fatalf("Stop() cleanup error = %v", err)
+ }
+ })
+
+ waitForManagerCondition(t, time.Second, func() bool {
+ lines, err := readFileLines(markerPath)
+ return err == nil && len(lines) >= 1
+ })
+
+ markers := readInitializeMarkers(t, markerPath)
+ if len(markers) == 0 {
+ t.Fatal("initialize markers = empty, want resource initialize handshake")
+ }
+ request := markers[0].Request
+ if strings.TrimSpace(request.SessionNonce) == "" {
+ t.Fatal("initialize session_nonce = empty, want daemon-issued nonce")
+ }
+ if !slicesEqualResourceKinds(request.Capabilities.GrantedResourceKinds, []resources.ResourceKind{resources.ResourceKind("tool")}) {
+ t.Fatalf(
+ "initialize granted_resource_kinds = %#v, want [tool]",
+ request.Capabilities.GrantedResourceKinds,
+ )
+ }
+ if !slicesEqualResourceScopes(
+ request.Capabilities.GrantedResourceScopes,
+ []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace},
+ ) {
+ t.Fatalf(
+ "initialize granted_resource_scopes = %#v, want [workspace]",
+ request.Capabilities.GrantedResourceScopes,
+ )
+ }
+}
+
func TestManagerIntegrationNonBridgeExtensionStartsWithoutBridgeNegotiation(t *testing.T) {
withDaemonVersion(t, "0.5.0")
@@ -430,6 +590,36 @@ func readInitializeMarkers(t *testing.T, path string) []managerInitializeMarker
return markers
}
+func slicesEqualResourceKinds(left []resources.ResourceKind, right []resources.ResourceKind) bool {
+ return slicesEqualStrings(resourceKindsToStrings(left), resourceKindsToStrings(right))
+}
+
+func slicesEqualResourceScopes(left []resources.ResourceScopeKind, right []resources.ResourceScopeKind) bool {
+ return slicesEqualStrings(resourceScopesToStrings(left), resourceScopesToStrings(right))
+}
+
+func resourceKindsToStrings(values []resources.ResourceKind) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ dst := make([]string, 0, len(values))
+ for _, value := range values {
+ dst = append(dst, string(value))
+ }
+ return dst
+}
+
+func resourceScopesToStrings(values []resources.ResourceScopeKind) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ dst := make([]string, 0, len(values))
+ for _, value := range values {
+ dst = append(dst, string(value))
+ }
+ return dst
+}
+
func readFileLines(path string) ([]string, error) {
payload, err := os.ReadFile(path)
if err != nil {
diff --git a/internal/extension/manager_test.go b/internal/extension/manager_test.go
index 1586e3dec..08a91ea0c 100644
--- a/internal/extension/manager_test.go
+++ b/internal/extension/manager_test.go
@@ -14,9 +14,11 @@ import (
"testing"
"time"
+ automationpkg "github.com/pedronauck/agh/internal/automation"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/subprocess"
"github.com/pedronauck/agh/internal/testutil"
@@ -38,6 +40,25 @@ func (noopBridgeTelemetrySink) RecordBridgeRuntimeIssue(string, bridgepkg.Bridge
func (noopBridgeTelemetrySink) ClearBridgeRuntimeIssue(string) {}
+type recordingBridgeTelemetrySink struct {
+ issues []string
+ clears []string
+}
+
+func (r *recordingBridgeTelemetrySink) RecordBridgeAuthFailure(string) {}
+
+func (r *recordingBridgeTelemetrySink) RecordBridgeRuntimeIssue(
+ bridgeInstanceID string,
+ status bridgepkg.BridgeStatus,
+ message string,
+) {
+ r.issues = append(r.issues, fmt.Sprintf("%s:%s:%s", bridgeInstanceID, status, message))
+}
+
+func (r *recordingBridgeTelemetrySink) ClearBridgeRuntimeIssue(bridgeInstanceID string) {
+ r.clears = append(r.clears, bridgeInstanceID)
+}
+
func TestExtensionManagerHelperProcess(_ *testing.T) {
if os.Getenv(extensionHelperEnvKey) != "1" {
return
@@ -72,11 +93,9 @@ func TestManagerStartRegistersResourcesAndActivatesExtension(t *testing.T) {
fakeProc := newFakeProcess(101)
launcher := &fakeLauncher{queue: []*fakeProcess{fakeProc}}
- skillsRegistry := skillspkg.NewRegistry(skillspkg.RegistryConfig{})
manager := NewManager(
env.registry,
- WithSkillsRegistry(skillsRegistry),
WithHostMethodHandler("sessions/list", func(_ context.Context, _ json.RawMessage) (any, error) {
return []map[string]string{{"id": "sess-1"}}, nil
}),
@@ -106,6 +125,9 @@ func TestManagerStartRegistersResourcesAndActivatesExtension(t *testing.T) {
if request.ProtocolVersion != defaultProtocolVersion {
t.Fatalf("initialize protocol version = %q, want %q", request.ProtocolVersion, defaultProtocolVersion)
}
+ if strings.TrimSpace(request.SessionNonce) == "" {
+ t.Fatal("initialize session nonce = empty, want daemon-issued nonce")
+ }
if !slices.Equal(request.Capabilities.GrantedActions, []extensionprotocol.HostAPIMethod{
extensionprotocol.HostAPIMethodSessionsList,
}) {
@@ -137,23 +159,22 @@ func TestManagerStartRegistersResourcesAndActivatesExtension(t *testing.T) {
t.Fatalf("AgentDefinitions() = %#v, want ext-agent", agents)
}
- servers := manager.MCPServers()
- if len(servers) != 1 || servers[0].Name != "kubectl" {
- t.Fatalf("MCPServers() = %#v, want kubectl server", servers)
- }
-
- skills := skillsRegistry.List()
- if len(skills) != 1 || skills[0].Meta.Name != "ext-review" {
- t.Fatalf("skills registry List() = %#v, want ext-review", skills)
- }
-
loaded, err := manager.Get("ext-runtime")
if err != nil {
t.Fatalf("Get(ext-runtime) error = %v", err)
}
+ if len(loaded.Skills) != 1 || loaded.Skills[0].Meta.Name != "ext-review" {
+ t.Fatalf("Get(ext-runtime).Skills = %#v, want ext-review extension snapshot", loaded.Skills)
+ }
if !loaded.Status.Active {
t.Fatalf("Get(ext-runtime).Status.Active = false, want true")
}
+ if loaded.Manifest == nil || loaded.Manifest.Resources.MCPServers["kubectl"].Command == "" {
+ t.Fatalf(
+ "Get(ext-runtime).Manifest.Resources.MCPServers = %#v, want kubectl manifest declaration",
+ loaded.Manifest,
+ )
+ }
if got, want := loaded.Status.Phase, ExtensionPhaseActivate; got != want {
t.Fatalf("Get(ext-runtime).Status.Phase = %q, want %q", got, want)
}
@@ -970,6 +991,11 @@ func TestManagerHelperPathsAndAccessors(t *testing.T) {
if !requiresSubprocess(&Manifest{Actions: ActionsConfig{Requires: []string{"sessions/list"}}}) {
t.Fatal("requiresSubprocess(actions-only) = false, want true")
}
+ if !requiresSubprocess(
+ &Manifest{Resources: ResourcesConfig{Publish: ResourceGrantRequest{Families: []string{"tools"}}}},
+ ) {
+ t.Fatal("requiresSubprocess(resource-publish) = false, want true")
+ }
if requiresSubprocess(&Manifest{}) {
t.Fatal("requiresSubprocess(empty manifest) = true, want false")
}
@@ -1107,6 +1133,10 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
Version: "1.0.0",
Resources: ResourcesConfig{
Skills: []string{"skills/"},
+ Publish: ResourceGrantRequest{
+ Families: []string{"tools"},
+ MaxScope: resources.ResourceScopeKindWorkspace,
+ },
},
Capabilities: CapabilitiesConfig{
Provides: []string{"memory.backend"},
@@ -1131,6 +1161,7 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
Description: "Snapshot skill",
Metadata: map[string]any{
"nested": map[string]any{"value": "original"},
+ "list": []any{"original", map[string]any{"child": "value"}},
},
},
Hooks: []hookspkg.HookDecl{{
@@ -1148,6 +1179,36 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
Hash: "hash-original",
},
}},
+ bundles: []BundleSpec{{
+ Name: "bundle-one",
+ Description: "Original bundle",
+ Profiles: []BundleProfile{{
+ Name: "default",
+ Description: "Default profile",
+ Channels: BundleChannelsConfig{
+ Primary: "primary",
+ Items: []BundleChannel{{Name: "primary", Description: "Primary"}},
+ },
+ Jobs: []BundleJob{{
+ Name: "job-one",
+ AgentName: "coder",
+ Prompt: "do work",
+ Task: &automationpkg.JobTaskConfig{
+ Title: "Job task",
+ },
+ Enabled: true,
+ }},
+ Bridges: []BundleBridgePreset{{
+ Name: "bridge-one",
+ DisplayName: "Bridge",
+ RoutingPolicy: bridgepkg.RoutingPolicy{
+ IncludePeer: true,
+ },
+ }},
+ }},
+ }},
+ grantedResourceKinds: []resources.ResourceKind{resources.ResourceKind("tool")},
+ grantedResourceScopes: []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace},
initialize: &subprocess.InitializeResponse{
ImplementedMethods: []string{"shutdown"},
SupportedHookEvents: []string{"turn.start"},
@@ -1168,11 +1229,18 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
clone.Info.Actions.Requires[0] = "changed"
clone.Manifest.Resources.Skills[0] = "changed"
clone.Manifest.Subprocess.Env["TOKEN"] = "changed"
+ clone.Manifest.Resources.Publish.Families[0] = "changed"
clone.Skills[0].Meta.Name = "changed"
clone.Skills[0].Meta.Metadata["nested"].(map[string]any)["value"] = "changed"
+ clone.Skills[0].Meta.Metadata["list"].([]any)[0] = "changed"
+ clone.Skills[0].Meta.Metadata["list"].([]any)[1].(map[string]any)["child"] = "changed"
clone.Skills[0].Hooks[0].Args[0] = "changed"
clone.Skills[0].MCPServers[0].Env["ROOT"] = "/tmp/changed"
clone.Skills[0].Provenance.Hash = "hash-changed"
+ clone.Bundles[0].Profiles[0].Jobs[0].Task.Title = "changed"
+ clone.Bundles[0].Profiles[0].Bridges[0].DisplayName = "changed"
+ clone.GrantedResourceKinds[0] = resources.ResourceKind("changed")
+ clone.GrantedResourceScopes[0] = resources.ResourceScopeKindGlobal
clone.InitializeResult.ImplementedMethods[0] = "changed"
clone.InitializeResult.AcceptedCapabilities.Provides[0] = "changed"
@@ -1185,6 +1253,9 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
if ext.manifest.Resources.Skills[0] != "skills/" {
t.Fatalf("original manifest resources mutated to %#v", ext.manifest.Resources.Skills)
}
+ if ext.manifest.Resources.Publish.Families[0] != "tools" {
+ t.Fatalf("original manifest publish request mutated to %#v", ext.manifest.Resources.Publish)
+ }
if ext.manifest.Subprocess.Env["TOKEN"] != "value" {
t.Fatalf("original manifest env mutated to %#v", ext.manifest.Subprocess.Env)
}
@@ -1194,6 +1265,9 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
if ext.skills[0].Meta.Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatalf("original skill metadata mutated to %#v", ext.skills[0].Meta.Metadata)
}
+ if ext.skills[0].Meta.Metadata["list"].([]any)[0] != "original" {
+ t.Fatalf("original skill metadata list mutated to %#v", ext.skills[0].Meta.Metadata["list"])
+ }
if ext.skills[0].Hooks[0].Args[0] != "cleanup" {
t.Fatalf("original skill hook args mutated to %#v", ext.skills[0].Hooks[0].Args)
}
@@ -1203,6 +1277,18 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
if ext.skills[0].Provenance.Hash != "hash-original" {
t.Fatalf("original skill provenance mutated to %#v", ext.skills[0].Provenance)
}
+ if ext.bundles[0].Profiles[0].Jobs[0].Task.Title != "Job task" {
+ t.Fatalf("original bundle job task mutated to %#v", ext.bundles[0].Profiles[0].Jobs[0].Task)
+ }
+ if ext.bundles[0].Profiles[0].Bridges[0].DisplayName != "Bridge" {
+ t.Fatalf("original bundle bridge mutated to %#v", ext.bundles[0].Profiles[0].Bridges[0])
+ }
+ if ext.grantedResourceKinds[0] != resources.ResourceKind("tool") {
+ t.Fatalf("original granted resource kinds mutated to %#v", ext.grantedResourceKinds)
+ }
+ if ext.grantedResourceScopes[0] != resources.ResourceScopeKindWorkspace {
+ t.Fatalf("original granted resource scopes mutated to %#v", ext.grantedResourceScopes)
+ }
if ext.initialize.ImplementedMethods[0] != "shutdown" {
t.Fatalf("original initialize methods mutated to %#v", ext.initialize.ImplementedMethods)
}
@@ -1211,6 +1297,33 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) {
}
}
+func TestManagerBridgeRuntimeIssueHelpers(t *testing.T) {
+ t.Parallel()
+
+ sink := &recordingBridgeTelemetrySink{}
+ manager := NewManager(nil, WithBridgeTelemetrySink(sink))
+
+ manager.reportBridgeRuntimeIssue(" bridge-1 ", bridgepkg.BridgeStatusDegraded, errors.New("boom"))
+ manager.reportBridgeRuntimeIssue("", bridgepkg.BridgeStatusReady, errors.New("ignored"))
+ manager.reportBridgeRuntimeIssues(
+ []string{"bridge-2", "bridge-3"},
+ bridgepkg.BridgeStatusError,
+ errors.New("failed"),
+ )
+ manager.clearBridgeRuntimeIssue(" bridge-1 ")
+ manager.clearBridgeRuntimeIssues([]string{"bridge-2", "bridge-3"})
+
+ if len(sink.issues) != 3 {
+ t.Fatalf("len(issues) = %d, want 3", len(sink.issues))
+ }
+ if len(sink.clears) != 3 {
+ t.Fatalf("len(clears) = %d, want 3", len(sink.clears))
+ }
+ if sink.issues[0] != "bridge-1:degraded:boom" {
+ t.Fatalf("issues[0] = %q, want bridge-1 degraded entry", sink.issues[0])
+ }
+}
+
func TestManagerDirectPhaseAndMonitorBranches(t *testing.T) {
t.Parallel()
@@ -1303,8 +1416,11 @@ func TestManagerDirectPhaseAndMonitorBranches(t *testing.T) {
},
},
}
- if err := manager.registerExtension(context.Background(), skillExt); err == nil {
- t.Fatal("registerExtension(skills without registry) error = nil, want registry-required error")
+ if err := manager.registerExtension(context.Background(), skillExt); err != nil {
+ t.Fatalf("registerExtension(skills) error = %v", err)
+ }
+ if len(skillExt.skills) != 1 || skillExt.skills[0].Meta.Name != "missing-registry" {
+ t.Fatalf("registerExtension(skills).skills = %#v, want loaded extension skill snapshot", skillExt.skills)
}
manager.capChecker.Register("ext-host", SourceUser, &Manifest{
@@ -1315,6 +1431,7 @@ func TestManagerDirectPhaseAndMonitorBranches(t *testing.T) {
"ext-host",
"sessions/list",
nil,
+ nil,
func(_ context.Context, _ json.RawMessage) (any, error) {
return "ok", nil
},
@@ -1323,10 +1440,67 @@ func TestManagerDirectPhaseAndMonitorBranches(t *testing.T) {
if err != nil || result != "ok" {
t.Fatalf("wrapHostHandler allowed call = (%v, %v), want (ok, nil)", result, err)
}
+
+ resourceSession := &hostAPIResourceSession{
+ Actor: resources.MutationActor{
+ Kind: resources.MutationActorKindExtension,
+ ID: "ext-host",
+ SessionNonce: "nonce-wrap",
+ Source: resources.ResourceSource{
+ Kind: resources.ResourceSourceKind("extension"),
+ ID: "ext-host",
+ },
+ MaxScope: resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ GrantedKinds: []resources.ResourceKind{"tool.definition"},
+ GrantedScopes: []resources.ResourceScopeKind{resources.ResourceScopeKindGlobal},
+ },
+ }
+ bridgeRuntime := &subprocess.InitializeBridgeRuntime{
+ ManagedInstances: []subprocess.InitializeBridgeManagedInstance{
+ {
+ Instance: bridgepkg.BridgeInstance{
+ ID: "brg-wrap",
+ ExtensionName: "ext-host",
+ },
+ },
+ },
+ }
+ injected := manager.wrapHostHandler(
+ "ext-host",
+ "sessions/list",
+ bridgeRuntime,
+ resourceSession,
+ func(ctx context.Context, _ json.RawMessage) (any, error) {
+ if got := hostAPIExtensionNameFromContext(ctx); got != "ext-host" {
+ t.Fatalf("hostAPIExtensionNameFromContext(ctx) = %q, want ext-host", got)
+ }
+ runtime := hostAPIBridgeRuntimeFromContext(ctx)
+ if runtime == nil {
+ t.Fatal("hostAPIBridgeRuntimeFromContext(ctx) = nil, want runtime")
+ }
+ if got := runtime.ManagedInstances[0].Instance.ID; got != "brg-wrap" {
+ t.Fatalf("runtime.ManagedInstances[0].Instance.ID = %q, want brg-wrap", got)
+ }
+ session, ok := hostAPIResourceSessionFromContext(ctx)
+ if !ok {
+ t.Fatal("hostAPIResourceSessionFromContext(ctx) = false, want true")
+ }
+ if got := session.Actor.SessionNonce; got != "nonce-wrap" {
+ t.Fatalf("session.Actor.SessionNonce = %q, want nonce-wrap", got)
+ }
+ return "injected", nil
+ },
+ )
+ result, err = injected(context.Background(), json.RawMessage(`{}`))
+ if err != nil || result != "injected" {
+ t.Fatalf("wrapHostHandler injected call = (%v, %v), want (injected, nil)", result, err)
+ }
+
denied := manager.wrapHostHandler(
"ext-denied",
"sessions/list",
nil,
+ nil,
func(_ context.Context, _ json.RawMessage) (any, error) {
return "never", nil
},
@@ -1382,6 +1556,8 @@ type managerManifestOptions struct {
withAgents bool
withHooks bool
withMCP bool
+ resourceFamilies []string
+ resourceMaxScope string
minVersion string
capabilities []string
actions []string
@@ -1967,6 +2143,18 @@ command = "mcp-kubectl"
args = ["--context", "prod"]
`)
}
+ if len(opts.resourceFamilies) > 0 || strings.TrimSpace(opts.resourceMaxScope) != "" {
+ builder.WriteString(`
+[resources.publish]
+`)
+ if len(opts.resourceFamilies) > 0 {
+ builder.WriteString("families = " + tomlStringArray(opts.resourceFamilies) + "\n")
+ }
+ if scope := strings.TrimSpace(opts.resourceMaxScope); scope != "" {
+ builder.WriteString(`max_scope = "` + scope + `"
+`)
+ }
+ }
builder.WriteString(`
[capabilities]
diff --git a/internal/extension/manifest.go b/internal/extension/manifest.go
index 9a2fc7428..5045db040 100644
--- a/internal/extension/manifest.go
+++ b/internal/extension/manifest.go
@@ -16,6 +16,8 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
+ "github.com/pedronauck/agh/internal/extension/surfaces"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/version"
)
@@ -55,7 +57,15 @@ type ResourcesConfig struct {
Agents []string `toml:"agents,omitempty" json:"agents,omitempty"`
Bundles []string `toml:"bundles,omitempty" json:"bundles,omitempty"`
Hooks []HookConfig `toml:"hooks,omitempty" json:"hooks,omitempty"`
+ Tools map[string]ToolConfig `toml:"tools,omitempty" json:"tools,omitempty"`
MCPServers map[string]MCPServerConfig `toml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"`
+ Publish ResourceGrantRequest `toml:"publish,omitempty" json:"publish"`
+}
+
+// ResourceGrantRequest declares the resource families and scope ceiling an extension requests.
+type ResourceGrantRequest struct {
+ Families []string `toml:"families,omitempty" json:"families,omitempty"`
+ MaxScope resources.ResourceScopeKind `toml:"max_scope,omitempty" json:"max_scope,omitempty"`
}
// CapabilitiesConfig declares the runtime interfaces the extension provides.
@@ -140,6 +150,13 @@ type MCPServerConfig struct {
Env map[string]string `toml:"env,omitempty" json:"env,omitempty"`
}
+// ToolConfig declares one static tool bundled by the extension.
+type ToolConfig struct {
+ Description string `toml:"description,omitempty" json:"description,omitempty"`
+ InputSchema json.RawMessage `toml:"input_schema,omitempty" json:"input_schema,omitempty"`
+ ReadOnly bool `toml:"read_only,omitempty" json:"read_only,omitempty"`
+}
+
// Duration stores time.Duration values while decoding TOML strings and JSON
// strings consistently.
type Duration time.Duration
@@ -262,6 +279,15 @@ func (m *Manifest) Validate() error {
}
}
}
+ if _, err := surfaces.ResolveManifestRequest(
+ m.Resources.Publish.Families,
+ m.Resources.Publish.MaxScope,
+ ); err != nil {
+ return &ManifestValidationError{
+ Field: "resources.publish",
+ Message: err.Error(),
+ }
+ }
return nil
}
@@ -480,7 +506,16 @@ func normalizeResourcesConfig(cfg ResourcesConfig) ResourcesConfig {
Agents: normalizeStrings(cfg.Agents),
Bundles: normalizeStrings(cfg.Bundles),
Hooks: normalizeHooks(cfg.Hooks),
+ Tools: normalizeTools(cfg.Tools),
MCPServers: normalizeMCPServers(cfg.MCPServers),
+ Publish: normalizeResourceGrantRequest(cfg.Publish),
+ }
+}
+
+func normalizeResourceGrantRequest(cfg ResourceGrantRequest) ResourceGrantRequest {
+ return ResourceGrantRequest{
+ Families: normalizeStrings(cfg.Families),
+ MaxScope: cfg.MaxScope.Normalize(),
}
}
@@ -636,6 +671,31 @@ func normalizeMCPServers(src map[string]MCPServerConfig) map[string]MCPServerCon
return dst
}
+func normalizeTools(src map[string]ToolConfig) map[string]ToolConfig {
+ if len(src) == 0 {
+ return nil
+ }
+
+ dst := make(map[string]ToolConfig, len(src))
+ for _, name := range sortedMapKeys(src) {
+ trimmedName := strings.TrimSpace(name)
+ if trimmedName == "" {
+ continue
+ }
+
+ tool := src[name]
+ dst[trimmedName] = ToolConfig{
+ Description: strings.TrimSpace(tool.Description),
+ InputSchema: cloneManifestRawMessage(tool.InputSchema),
+ ReadOnly: tool.ReadOnly,
+ }
+ }
+ if len(dst) == 0 {
+ return nil
+ }
+ return dst
+}
+
func normalizeStrings(src []string) []string {
if len(src) == 0 {
return nil
@@ -699,6 +759,13 @@ func cloneBoolPointer(value *bool) *bool {
return &cloned
}
+func cloneManifestRawMessage(src json.RawMessage) json.RawMessage {
+ if len(src) == 0 {
+ return nil
+ }
+ return append(json.RawMessage(nil), src...)
+}
+
func requireField(field, value string) error {
if strings.TrimSpace(value) != "" {
return nil
diff --git a/internal/extension/manifest_test.go b/internal/extension/manifest_test.go
index 39ae824a0..9ea69acdc 100644
--- a/internal/extension/manifest_test.go
+++ b/internal/extension/manifest_test.go
@@ -11,6 +11,7 @@ import (
bridgepkg "github.com/pedronauck/agh/internal/bridges"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/version"
)
@@ -140,6 +141,66 @@ func TestNormalizeMCPServersDropsBlankKeysAndUsesDeterministicCollisions(t *test
}
}
+func TestNormalizeToolsDropsBlankKeysAndUsesDeterministicCollisions(t *testing.T) {
+ t.Parallel()
+
+ got := normalizeTools(map[string]ToolConfig{
+ " ": {
+ Description: "ignored",
+ },
+ " lookup ": {
+ Description: " first ",
+ InputSchema: json.RawMessage(`{"type":"object","title":"First"}`),
+ },
+ "lookup": {
+ Description: " second ",
+ InputSchema: json.RawMessage(`{"type":"object","title":"Second"}`),
+ ReadOnly: true,
+ },
+ })
+
+ want := map[string]ToolConfig{
+ "lookup": {
+ Description: "second",
+ InputSchema: json.RawMessage(`{"type":"object","title":"Second"}`),
+ ReadOnly: true,
+ },
+ }
+
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("normalizeTools() = %#v, want %#v", got, want)
+ }
+}
+
+func TestLoadManifest_ParsesResourcePublishRequest(t *testing.T) {
+ withDaemonVersion(t, "0.6.0")
+
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, manifestTOMLFileName), `[extension]
+name = "resource-grants"
+version = "0.2.1"
+min_agh_version = "0.5.0"
+
+[resources.publish]
+families = ["tools", "mcp_servers"]
+max_scope = "workspace"
+
+[subprocess]
+command = "agh-ext-resource-grants"
+`)
+
+ manifest, err := LoadManifest(dir)
+ if err != nil {
+ t.Fatalf("LoadManifest() error = %v, want nil", err)
+ }
+ if !reflect.DeepEqual(manifest.Resources.Publish.Families, []string{"tools", "mcp_servers"}) {
+ t.Fatalf("Resources.Publish.Families = %#v, want tools+mcp_servers", manifest.Resources.Publish.Families)
+ }
+ if got, want := manifest.Resources.Publish.MaxScope, resources.ResourceScopeKindWorkspace; got != want {
+ t.Fatalf("Resources.Publish.MaxScope = %q, want %q", got, want)
+ }
+}
+
func TestNormalizeStringMapDropsBlankKeysAndUsesDeterministicCollisions(t *testing.T) {
t.Parallel()
@@ -311,6 +372,52 @@ min_agh_version = "0.5.0"
}
}
+func TestManifestValidateRejectsDaemonOnlyResourcePublishFamily(t *testing.T) {
+ t.Parallel()
+
+ manifest := expectedManifest()
+ manifest.Resources.Publish = ResourceGrantRequest{
+ Families: []string{"bridge_instances"},
+ MaxScope: resources.ResourceScopeKindGlobal,
+ }
+
+ err := manifest.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want non-nil")
+ }
+
+ var validationErr *ManifestValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("Validate() error type = %T, want *ManifestValidationError", err)
+ }
+ if got, want := validationErr.Field, "resources.publish"; got != want {
+ t.Fatalf("Validate() field = %q, want %q", got, want)
+ }
+}
+
+func TestManifestValidateRejectsInvalidResourcePublishScope(t *testing.T) {
+ t.Parallel()
+
+ manifest := expectedManifest()
+ manifest.Resources.Publish = ResourceGrantRequest{
+ Families: []string{"tools"},
+ MaxScope: resources.ResourceScopeKind("session"),
+ }
+
+ err := manifest.Validate()
+ if err == nil {
+ t.Fatal("Validate() error = nil, want non-nil")
+ }
+
+ var validationErr *ManifestValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("Validate() error type = %T, want *ManifestValidationError", err)
+ }
+ if got, want := validationErr.Field, "resources.publish"; got != want {
+ t.Fatalf("Validate() field = %q, want %q", got, want)
+ }
+}
+
func TestLoadManifest_PrefersTOMLWhenBothFilesExist(t *testing.T) {
withDaemonVersion(t, "0.6.0")
@@ -800,6 +907,12 @@ func expectedManifest() Manifest {
},
},
},
+ Tools: map[string]ToolConfig{
+ "lookup": {
+ Description: "Search workspace content",
+ ReadOnly: true,
+ },
+ },
MCPServers: map[string]MCPServerConfig{
"kubectl": {
Command: "mcp-kubectl",
@@ -841,6 +954,10 @@ min_agh_version = "0.5.0"
skills = ["skills/"]
agents = ["agents/"]
+[resources.tools.lookup]
+description = "Search workspace content"
+read_only = true
+
[[resources.hooks]]
name = "workspace-context"
event = "prompt.post_assemble"
@@ -893,6 +1010,12 @@ const validManifestJSON = `{
"resources": {
"skills": ["skills/"],
"agents": ["agents/"],
+ "tools": {
+ "lookup": {
+ "description": "Search workspace content",
+ "read_only": true
+ }
+ },
"hooks": [
{
"name": "workspace-context",
diff --git a/internal/extension/protocol/host_api.go b/internal/extension/protocol/host_api.go
index d052a8340..3b4fd58cc 100644
--- a/internal/extension/protocol/host_api.go
+++ b/internal/extension/protocol/host_api.go
@@ -32,6 +32,9 @@ const (
HostAPIMethodSessionsStop HostAPIMethod = "sessions/stop"
HostAPIMethodSessionsStatus HostAPIMethod = "sessions/status"
HostAPIMethodSessionsEvents HostAPIMethod = "sessions/events"
+ HostAPIMethodEnvironmentList HostAPIMethod = "environment/list"
+ HostAPIMethodEnvironmentInfo HostAPIMethod = "environment/info"
+ HostAPIMethodEnvironmentExec HostAPIMethod = "environment/exec"
HostAPIMethodMemoryRecall HostAPIMethod = "memory/recall"
HostAPIMethodMemoryStore HostAPIMethod = "memory/store"
HostAPIMethodMemoryForget HostAPIMethod = "memory/forget"
@@ -66,6 +69,9 @@ const (
HostAPIMethodTasksRunsComplete HostAPIMethod = "tasks/runs/complete"
HostAPIMethodTasksRunsFail HostAPIMethod = "tasks/runs/fail"
HostAPIMethodTasksRunsCancel HostAPIMethod = "tasks/runs/cancel"
+ HostAPIMethodResourcesList HostAPIMethod = "resources/list"
+ HostAPIMethodResourcesGet HostAPIMethod = "resources/get"
+ HostAPIMethodResourcesSnapshot HostAPIMethod = "resources/snapshot"
HostAPIMethodBridgesInstancesList HostAPIMethod = "bridges/instances/list"
HostAPIMethodBridgesMessagesIngest HostAPIMethod = "bridges/messages/ingest"
HostAPIMethodBridgesInstancesGet HostAPIMethod = "bridges/instances/get"
@@ -81,6 +87,9 @@ func AllHostAPIMethods() []HostAPIMethod {
HostAPIMethodSessionsStop,
HostAPIMethodSessionsStatus,
HostAPIMethodSessionsEvents,
+ HostAPIMethodEnvironmentList,
+ HostAPIMethodEnvironmentInfo,
+ HostAPIMethodEnvironmentExec,
HostAPIMethodMemoryRecall,
HostAPIMethodMemoryStore,
HostAPIMethodMemoryForget,
@@ -115,6 +124,9 @@ func AllHostAPIMethods() []HostAPIMethod {
HostAPIMethodTasksRunsComplete,
HostAPIMethodTasksRunsFail,
HostAPIMethodTasksRunsCancel,
+ HostAPIMethodResourcesList,
+ HostAPIMethodResourcesGet,
+ HostAPIMethodResourcesSnapshot,
HostAPIMethodBridgesInstancesList,
HostAPIMethodBridgesMessagesIngest,
HostAPIMethodBridgesInstancesGet,
diff --git a/internal/extension/protocol/host_api_test.go b/internal/extension/protocol/host_api_test.go
index ba237a58c..7424289b0 100644
--- a/internal/extension/protocol/host_api_test.go
+++ b/internal/extension/protocol/host_api_test.go
@@ -12,6 +12,9 @@ func TestAllHostAPIMethodsReturnsCanonicalWireOrder(t *testing.T) {
HostAPIMethodSessionsStop,
HostAPIMethodSessionsStatus,
HostAPIMethodSessionsEvents,
+ HostAPIMethodEnvironmentList,
+ HostAPIMethodEnvironmentInfo,
+ HostAPIMethodEnvironmentExec,
HostAPIMethodMemoryRecall,
HostAPIMethodMemoryStore,
HostAPIMethodMemoryForget,
@@ -46,6 +49,9 @@ func TestAllHostAPIMethodsReturnsCanonicalWireOrder(t *testing.T) {
HostAPIMethodTasksRunsComplete,
HostAPIMethodTasksRunsFail,
HostAPIMethodTasksRunsCancel,
+ HostAPIMethodResourcesList,
+ HostAPIMethodResourcesGet,
+ HostAPIMethodResourcesSnapshot,
HostAPIMethodBridgesInstancesList,
HostAPIMethodBridgesMessagesIngest,
HostAPIMethodBridgesInstancesGet,
diff --git a/internal/extension/reference_integration_test.go b/internal/extension/reference_integration_test.go
index 1d183f4a2..d5727a859 100644
--- a/internal/extension/reference_integration_test.go
+++ b/internal/extension/reference_integration_test.go
@@ -307,7 +307,7 @@ func newReferenceHarness(t *testing.T, repoRoot string) *referenceHarness {
logger := slog.New(slog.NewTextHandler(harness.logBuffer, nil))
daemon, err := daemonpkg.New(
daemonpkg.WithHomePaths(homePaths),
- daemonpkg.WithConfig(cfg),
+ daemonpkg.WithConfig(&cfg),
daemonpkg.WithLogger(logger),
)
if err != nil {
diff --git a/internal/extension/registry.go b/internal/extension/registry.go
index 3f6e976e0..d87a1dc34 100644
--- a/internal/extension/registry.go
+++ b/internal/extension/registry.go
@@ -148,6 +148,15 @@ func NewRegistry(db *sql.DB) *Registry {
}
}
+// DB exposes the backing SQLite handle for composition-root integrations that
+// need to build additional stores over the same registry database.
+func (r *Registry) DB() *sql.DB {
+ if r == nil {
+ return nil
+ }
+ return r.db
+}
+
// Install verifies the extension artifact checksum and persists the install as
// a user-sourced extension.
func (r *Registry) Install(manifest *Manifest, path string, checksum string, opts ...InstallOption) error {
@@ -521,18 +530,41 @@ func (r *Registry) checkReady(action string) error {
}
func (r *Registry) ensureNoActiveBundles(extensionName string) error {
- var count int
- row := r.db.QueryRowContext(
+ rows, err := r.db.QueryContext(
registryContext(),
- `SELECT COUNT(*) FROM bundle_activations WHERE extension_name = ?`,
- strings.TrimSpace(extensionName),
+ `SELECT spec_json FROM resource_records WHERE kind = ?`,
+ "bundle.activation",
)
- if err := row.Scan(&count); err != nil {
+ if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no such table") {
return nil
}
return fmt.Errorf("extension: count active bundle activations for %q: %w", extensionName, err)
}
+ defer func() {
+ _ = rows.Close()
+ }()
+
+ count := 0
+ trimmedName := strings.TrimSpace(extensionName)
+ for rows.Next() {
+ var raw string
+ if err := rows.Scan(&raw); err != nil {
+ return fmt.Errorf("extension: scan active bundle activation for %q: %w", extensionName, err)
+ }
+ var spec struct {
+ ExtensionName string `json:"extension_name"`
+ }
+ if err := json.Unmarshal([]byte(raw), &spec); err != nil {
+ return fmt.Errorf("extension: decode active bundle activation for %q: %w", extensionName, err)
+ }
+ if strings.TrimSpace(spec.ExtensionName) == trimmedName {
+ count++
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("extension: iterate active bundle activations for %q: %w", extensionName, err)
+ }
if count > 0 {
return fmt.Errorf("%w: %q has %d active activation(s)", ErrExtensionHasActiveBundles, extensionName, count)
}
diff --git a/internal/extension/registry_bundles_test.go b/internal/extension/registry_bundles_test.go
index 0f0c93e26..6e222ac06 100644
--- a/internal/extension/registry_bundles_test.go
+++ b/internal/extension/registry_bundles_test.go
@@ -10,6 +10,7 @@ import (
"time"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/testutil"
)
@@ -25,17 +26,20 @@ func TestRegistryBlocksDisableAndUninstallWithActiveBundles(t *testing.T) {
if _, err := env.db.ExecContext(
testutil.Context(t),
- `INSERT INTO bundle_activations (
- id, extension_name, bundle_name, profile_name, scope, workspace_id, spec_content_hash, bind_primary_channel_default, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO resource_records (
+ kind, id, version, scope_kind, scope_id, owner_kind, owner_id,
+ source_kind, source_id, spec_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ "bundle.activation",
"act_guard",
- manifest.Name,
- "bundle",
- "default",
+ 1,
"global",
nil,
- "hash",
- false,
+ "daemon",
+ "bundle-test",
+ "daemon",
+ "bundle-test",
+ `{"extension_name":"`+manifest.Name+`","bundle_name":"bundle","profile_name":"default"}`,
store.FormatTimestamp(env.installedAt),
store.FormatTimestamp(env.installedAt),
); err != nil {
@@ -55,21 +59,10 @@ func newRegistryTestEnvWithBundleActivations(t *testing.T) registryTestEnv {
dbPath := t.TempDir() + "/agh-registry.db"
db, err := store.OpenSQLiteDatabase(testutil.Context(t), dbPath, func(ctx context.Context, db *sql.DB) error {
- return store.EnsureSchema(ctx, db, []string{
+ statements := append([]string{
registryTestExtensionsTableSchema,
- `CREATE TABLE IF NOT EXISTS bundle_activations (
- id TEXT PRIMARY KEY,
- extension_name TEXT NOT NULL,
- bundle_name TEXT NOT NULL,
- profile_name TEXT NOT NULL,
- scope TEXT NOT NULL,
- workspace_id TEXT,
- spec_content_hash TEXT,
- bind_primary_channel_default BOOLEAN NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- );`,
- })
+ }, resources.SchemaStatements()...)
+ return store.EnsureSchema(ctx, db, statements)
})
if err != nil {
t.Fatalf("OpenSQLiteDatabase() error = %v", err)
diff --git a/internal/extension/registry_test.go b/internal/extension/registry_test.go
index e05237d5f..c45a12f95 100644
--- a/internal/extension/registry_test.go
+++ b/internal/extension/registry_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/testutil"
)
@@ -1033,7 +1034,8 @@ func newRegistryTestEnv(t *testing.T) registryTestEnv {
dbPath := filepath.Join(t.TempDir(), "agh-registry.db")
db, err := store.OpenSQLiteDatabase(testutil.Context(t), dbPath, func(ctx context.Context, db *sql.DB) error {
- return store.EnsureSchema(ctx, db, []string{registryTestExtensionsTableSchema})
+ schema := append([]string{registryTestExtensionsTableSchema}, resources.SchemaStatements()...)
+ return store.EnsureSchema(ctx, db, schema)
})
if err != nil {
t.Fatalf("OpenSQLiteDatabase() error = %v", err)
@@ -1055,6 +1057,20 @@ func newRegistryTestEnv(t *testing.T) registryTestEnv {
}
}
+func TestRegistryDBReturnsBackingHandleAndNilSafe(t *testing.T) {
+ t.Parallel()
+
+ var nilRegistry *Registry
+ if got := nilRegistry.DB(); got != nil {
+ t.Fatalf("(*Registry)(nil).DB() = %#v, want nil", got)
+ }
+
+ env := newRegistryTestEnv(t)
+ if got, want := env.registry.DB(), env.db; got != want {
+ t.Fatalf("registry.DB() = %#v, want %#v", got, want)
+ }
+}
+
func createRegistryTestExtension(t *testing.T, name string, opts registryManifestOptions) (string, *Manifest, string) {
t.Helper()
diff --git a/internal/extension/resource_publication.go b/internal/extension/resource_publication.go
new file mode 100644
index 000000000..784fe5693
--- /dev/null
+++ b/internal/extension/resource_publication.go
@@ -0,0 +1,165 @@
+package extensionpkg
+
+import (
+ "fmt"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ aghconfig "github.com/pedronauck/agh/internal/config"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
+)
+
+// ResolveManifestToolResources converts manifest tool declarations into tool specs.
+func ResolveManifestToolResources(manifest *Manifest) []toolspkg.Tool {
+ if manifest == nil || len(manifest.Resources.Tools) == 0 {
+ return nil
+ }
+
+ names := make([]string, 0, len(manifest.Resources.Tools))
+ for name := range manifest.Resources.Tools {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+
+ tools := make([]toolspkg.Tool, 0, len(names))
+ for _, name := range names {
+ cfg := manifest.Resources.Tools[name]
+ tools = append(tools, toolspkg.Tool{
+ Name: strings.TrimSpace(name),
+ Description: strings.TrimSpace(cfg.Description),
+ InputSchema: cloneRawMessage(cfg.InputSchema),
+ ReadOnly: cfg.ReadOnly,
+ Source: toolspkg.ToolSourceExtension,
+ })
+ }
+ return tools
+}
+
+// ResolveManifestMCPServerResources converts manifest MCP declarations into MCP server specs.
+func ResolveManifestMCPServerResources(
+ rootDir string,
+ manifest *Manifest,
+ getenv func(string) string,
+) ([]aghconfig.MCPServer, error) {
+ if manifest == nil || len(manifest.Resources.MCPServers) == 0 {
+ return nil, nil
+ }
+
+ names := make([]string, 0, len(manifest.Resources.MCPServers))
+ for name := range manifest.Resources.MCPServers {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+
+ servers := make([]aghconfig.MCPServer, 0, len(names))
+ for _, name := range names {
+ decl := manifest.Resources.MCPServers[name]
+ command, err := resolveManifestCommand(rootDir, decl.Command, getenv)
+ if err != nil {
+ return nil, err
+ }
+ args, err := resolveManifestStringSlice(rootDir, decl.Args, getenv)
+ if err != nil {
+ return nil, err
+ }
+ env, err := resolveManifestStringMap(rootDir, decl.Env, getenv)
+ if err != nil {
+ return nil, err
+ }
+ server := aghconfig.MCPServer{
+ Name: strings.TrimSpace(name),
+ Command: command,
+ Args: args,
+ Env: env,
+ }
+ if err := server.Validate("extension.resources.mcp_servers[" + name + "]"); err != nil {
+ return nil, err
+ }
+ servers = append(servers, server)
+ }
+ return servers, nil
+}
+
+func resolveManifestCommand(rootDir string, value string, getenv func(string) string) (string, error) {
+ resolved, err := resolveManifestString(rootDir, value, getenv)
+ if err != nil {
+ return "", err
+ }
+ if resolved == "" {
+ return "", nil
+ }
+ if filepath.IsAbs(resolved) {
+ return filepath.Clean(resolved), nil
+ }
+ if strings.ContainsRune(resolved, filepath.Separator) || strings.HasPrefix(resolved, ".") {
+ return resolvePathWithinRoot(rootDir, resolved)
+ }
+ return resolved, nil
+}
+
+func resolveManifestStringSlice(rootDir string, values []string, getenv func(string) string) ([]string, error) {
+ if len(values) == 0 {
+ return nil, nil
+ }
+
+ resolved := make([]string, 0, len(values))
+ for _, value := range values {
+ item, err := resolveManifestString(rootDir, value, getenv)
+ if err != nil {
+ return nil, err
+ }
+ resolved = append(resolved, item)
+ }
+ return resolved, nil
+}
+
+func resolveManifestStringMap(
+ rootDir string,
+ env map[string]string,
+ getenv func(string) string,
+) (map[string]string, error) {
+ if len(env) == 0 {
+ return nil, nil
+ }
+
+ resolved := make(map[string]string, len(env))
+ for key, value := range env {
+ item, err := resolveManifestString(rootDir, value, getenv)
+ if err != nil {
+ return nil, err
+ }
+ resolved[key] = item
+ }
+ return resolved, nil
+}
+
+func resolveManifestString(rootDir string, value string, getenv func(string) string) (string, error) {
+ resolved := strings.TrimSpace(value)
+ if resolved == "" {
+ return "", nil
+ }
+
+ resolved = strings.ReplaceAll(resolved, "{{config_dir}}", rootDir)
+ for {
+ start := strings.Index(resolved, "{{env:")
+ if start < 0 {
+ break
+ }
+ end := strings.Index(resolved[start:], "}}")
+ if end < 0 {
+ return "", fmt.Errorf("invalid env template %q", value)
+ }
+ end += start
+ key := strings.TrimSpace(strings.TrimPrefix(resolved[start:end], "{{env:"))
+ resolved = resolved[:start] + getenvValue(getenv, key) + resolved[end+2:]
+ }
+ return resolved, nil
+}
+
+func getenvValue(getenv func(string) string, key string) string {
+ if getenv == nil {
+ return ""
+ }
+ return getenv(key)
+}
diff --git a/internal/extension/resource_publication_test.go b/internal/extension/resource_publication_test.go
new file mode 100644
index 000000000..65244f6ec
--- /dev/null
+++ b/internal/extension/resource_publication_test.go
@@ -0,0 +1,148 @@
+package extensionpkg
+
+import (
+ "bytes"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/testutil"
+ toolspkg "github.com/pedronauck/agh/internal/tools"
+)
+
+func TestResolveManifestToolResourcesMatchesDynamicSnapshotCanonicalShape(t *testing.T) {
+ t.Parallel()
+
+ manifest := &Manifest{
+ Resources: ResourcesConfig{
+ Tools: map[string]ToolConfig{
+ " lookup ": {
+ Description: " search workspace ",
+ InputSchema: json.RawMessage(`{
+ "properties": {"path": {"type": "string"}},
+ "type": "object"
+ }`),
+ ReadOnly: true,
+ },
+ },
+ },
+ }
+
+ tools := ResolveManifestToolResources(manifest)
+ if got, want := len(tools), 1; got != want {
+ t.Fatalf("len(ResolveManifestToolResources()) = %d, want %d", got, want)
+ }
+
+ codec, err := toolspkg.NewResourceCodec()
+ if err != nil {
+ t.Fatalf("toolspkg.NewResourceCodec() error = %v", err)
+ }
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+
+ manifestCanonical := mustCanonicalToolJSON(t, codec, scope, tools[0])
+ dynamicSpec, err := codec.DecodeAndValidate(testutil.Context(t), scope, []byte(`{
+ "name": "lookup",
+ "description": "search workspace",
+ "input_schema": {
+ "type": "object",
+ "properties": {"path": {"type": "string"}}
+ },
+ "read_only": true,
+ "source": "extension"
+ }`))
+ if err != nil {
+ t.Fatalf("codec.DecodeAndValidate(dynamic) error = %v", err)
+ }
+ dynamicCanonical := mustCanonicalToolJSON(t, codec, scope, dynamicSpec)
+
+ if !bytes.Equal(manifestCanonical, dynamicCanonical) {
+ t.Fatalf(
+ "manifest canonical tool != dynamic canonical tool\nmanifest=%s\ndynamic=%s",
+ string(manifestCanonical),
+ string(dynamicCanonical),
+ )
+ }
+}
+
+func TestResolveManifestMCPServerResourcesResolvesTemplates(t *testing.T) {
+ t.Parallel()
+
+ rootDir := t.TempDir()
+ manifest := &Manifest{
+ Resources: ResourcesConfig{
+ MCPServers: map[string]MCPServerConfig{
+ "git": {
+ Command: "./bin/mcp-git",
+ Args: []string{"--config", "{{config_dir}}/git.toml"},
+ Env: map[string]string{
+ "TOKEN": "{{env:GIT_TOKEN}}",
+ },
+ },
+ },
+ },
+ }
+
+ servers, err := ResolveManifestMCPServerResources(rootDir, manifest, func(key string) string {
+ if key == "GIT_TOKEN" {
+ return "secret-token"
+ }
+ return ""
+ })
+ if err != nil {
+ t.Fatalf("ResolveManifestMCPServerResources() error = %v", err)
+ }
+ if got, want := len(servers), 1; got != want {
+ t.Fatalf("len(ResolveManifestMCPServerResources()) = %d, want %d", got, want)
+ }
+ if got, want := servers[0].Command, filepath.Join(rootDir, "bin", "mcp-git"); got != want {
+ t.Fatalf("servers[0].Command = %q, want %q", got, want)
+ }
+ if got, want := servers[0].Args, []string{
+ "--config",
+ filepath.Join(rootDir, "git.toml"),
+ }; !equalStrings(
+ got,
+ want,
+ ) {
+ t.Fatalf("servers[0].Args = %#v, want %#v", got, want)
+ }
+ if got, want := servers[0].Env["TOKEN"], "secret-token"; got != want {
+ t.Fatalf("servers[0].Env[TOKEN] = %q, want %q", got, want)
+ }
+}
+
+func mustCanonicalToolJSON(
+ t *testing.T,
+ codec resources.KindCodec[toolspkg.Tool],
+ scope resources.ResourceScope,
+ spec toolspkg.Tool,
+) []byte {
+ t.Helper()
+
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ t.Fatalf("codec.Encode() error = %v", err)
+ }
+ validated, err := codec.DecodeAndValidate(testutil.Context(t), scope, encoded)
+ if err != nil {
+ t.Fatalf("codec.DecodeAndValidate() error = %v", err)
+ }
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ t.Fatalf("codec.Encode(validated) error = %v", err)
+ }
+ return canonical
+}
+
+func equalStrings(got []string, want []string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for idx := range got {
+ if got[idx] != want[idx] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/extension/surfaces/registry.go b/internal/extension/surfaces/registry.go
new file mode 100644
index 000000000..de2dbdec7
--- /dev/null
+++ b/internal/extension/surfaces/registry.go
@@ -0,0 +1,324 @@
+// Package surfaces defines the static extension resource surface policy.
+package surfaces
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+// ManifestFamily identifies one family-oriented manifest request name.
+type ManifestFamily string
+
+const (
+ FamilyHooks ManifestFamily = "hooks"
+ FamilyTools ManifestFamily = "tools"
+ FamilyAgents ManifestFamily = "agents"
+ FamilyMCPServers ManifestFamily = "mcp_servers"
+ FamilySkills ManifestFamily = "skills"
+ FamilyAutomationJobs ManifestFamily = "automation_jobs"
+ FamilyAutomationTriggers ManifestFamily = "automation_triggers"
+ FamilyBundles ManifestFamily = "bundles"
+ FamilyBridgeInstances ManifestFamily = "bridge_instances"
+ FamilyBundleActivations ManifestFamily = "bundle_activations"
+)
+
+// Surface declares the daemon-authoritative resource publication metadata for one kind.
+type Surface struct {
+ Kind resources.ResourceKind
+ ManifestFamily ManifestFamily
+ ExtensionPublish bool
+ LegalScopes []resources.ResourceScopeKind
+}
+
+// GrantRequest is the validated manifest request shape resolved from family names.
+type GrantRequest struct {
+ Kinds []resources.ResourceKind
+ Scopes []resources.ResourceScopeKind
+ MaxScope resources.ResourceScopeKind
+}
+
+var (
+ globalAndWorkspaceScopes = []resources.ResourceScopeKind{
+ resources.ResourceScopeKindGlobal,
+ resources.ResourceScopeKindWorkspace,
+ }
+ registry = []Surface{
+ {
+ Kind: resources.ResourceKind("hook.binding"),
+ ManifestFamily: FamilyHooks,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("tool"),
+ ManifestFamily: FamilyTools,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("agent"),
+ ManifestFamily: FamilyAgents,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("mcp_server"),
+ ManifestFamily: FamilyMCPServers,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("skill"),
+ ManifestFamily: FamilySkills,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("automation.job"),
+ ManifestFamily: FamilyAutomationJobs,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("automation.trigger"),
+ ManifestFamily: FamilyAutomationTriggers,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("bundle"),
+ ManifestFamily: FamilyBundles,
+ ExtensionPublish: true,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("bridge.instance"),
+ ManifestFamily: FamilyBridgeInstances,
+ ExtensionPublish: false,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ {
+ Kind: resources.ResourceKind("bundle.activation"),
+ ManifestFamily: FamilyBundleActivations,
+ ExtensionPublish: false,
+ LegalScopes: cloneScopes(globalAndWorkspaceScopes),
+ },
+ }
+ registryByKind = buildRegistryByKind(registry)
+ registryByManifestName = buildRegistryByManifestName(registry)
+)
+
+// All returns the full static surface registry.
+func All() []Surface {
+ cloned := make([]Surface, 0, len(registry))
+ for _, surface := range registry {
+ cloned = append(cloned, cloneSurface(surface))
+ }
+ return cloned
+}
+
+// Lookup resolves one resource kind from the static registry.
+func Lookup(kind resources.ResourceKind) (Surface, bool) {
+ surface, ok := registryByKind[kind.Normalize()]
+ if !ok {
+ return Surface{}, false
+ }
+ return cloneSurface(surface), true
+}
+
+// PublishableKinds returns the kinds that extensions may publish.
+func PublishableKinds() []resources.ResourceKind {
+ kinds := make([]resources.ResourceKind, 0, len(registry))
+ for _, surface := range registry {
+ if !surface.ExtensionPublish {
+ continue
+ }
+ kinds = append(kinds, surface.Kind)
+ }
+ slices.Sort(kinds)
+ return kinds
+}
+
+// ResolveManifestRequest validates one family-oriented manifest request against the surface table.
+func ResolveManifestRequest(
+ families []string,
+ maxScope resources.ResourceScopeKind,
+) (GrantRequest, error) {
+ normalizedFamilies := normalizeFamilies(families)
+ normalizedMaxScope := maxScope.Normalize()
+ if len(normalizedFamilies) == 0 {
+ if normalizedMaxScope != "" {
+ return GrantRequest{}, fmt.Errorf("resources.publish.max_scope requires at least one family")
+ }
+ return GrantRequest{}, nil
+ }
+ if normalizedMaxScope == "" {
+ normalizedMaxScope = resources.ResourceScopeKindGlobal
+ }
+ if err := normalizedMaxScope.Validate("resources.publish.max_scope"); err != nil {
+ return GrantRequest{}, err
+ }
+
+ requestedKinds := make([]resources.ResourceKind, 0, len(normalizedFamilies))
+ legalScopes := cloneScopes(globalAndWorkspaceScopes)
+ for _, family := range normalizedFamilies {
+ surface, ok := registryByManifestName[family]
+ if !ok {
+ return GrantRequest{}, fmt.Errorf("unknown manifest resource family %q", family)
+ }
+ if !surface.ExtensionPublish {
+ return GrantRequest{}, fmt.Errorf("manifest resource family %q is daemon-only", family)
+ }
+ requestedKinds = append(requestedKinds, surface.Kind)
+ legalScopes = intersectScopes(legalScopes, surface.LegalScopes)
+ }
+ requestedKinds = normalizeKinds(requestedKinds)
+ allowedScopes := intersectScopes(legalScopes, scopesThrough(normalizedMaxScope))
+ if len(allowedScopes) == 0 {
+ return GrantRequest{}, fmt.Errorf(
+ "resources.publish.max_scope %q is not legal for requested kinds",
+ normalizedMaxScope,
+ )
+ }
+ return GrantRequest{
+ Kinds: requestedKinds,
+ Scopes: allowedScopes,
+ MaxScope: normalizedMaxScope,
+ }, nil
+}
+
+// NormalizeAllowedKinds validates and normalizes operator-config allowlisted kinds.
+func NormalizeAllowedKinds(kinds []resources.ResourceKind) ([]resources.ResourceKind, error) {
+ normalized := normalizeKinds(kinds)
+ for _, kind := range normalized {
+ surface, ok := registryByKind[kind]
+ if !ok {
+ return nil, fmt.Errorf("unknown extension resource kind %q", kind)
+ }
+ if !surface.ExtensionPublish {
+ return nil, fmt.Errorf("resource kind %q is daemon-only", kind)
+ }
+ }
+ return normalized, nil
+}
+
+func buildRegistryByKind(values []Surface) map[resources.ResourceKind]Surface {
+ index := make(map[resources.ResourceKind]Surface, len(values))
+ for _, surface := range values {
+ index[surface.Kind.Normalize()] = cloneSurface(surface)
+ }
+ return index
+}
+
+func buildRegistryByManifestName(values []Surface) map[string]Surface {
+ index := make(map[string]Surface, len(values))
+ for _, surface := range values {
+ index[strings.TrimSpace(string(surface.ManifestFamily))] = cloneSurface(surface)
+ }
+ return index
+}
+
+func cloneSurface(surface Surface) Surface {
+ return Surface{
+ Kind: surface.Kind,
+ ManifestFamily: surface.ManifestFamily,
+ ExtensionPublish: surface.ExtensionPublish,
+ LegalScopes: cloneScopes(surface.LegalScopes),
+ }
+}
+
+func normalizeFamilies(values []string) []string {
+ if len(values) == 0 {
+ return nil
+ }
+
+ seen := make(map[string]struct{}, len(values))
+ families := make([]string, 0, len(values))
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ families = append(families, trimmed)
+ }
+ if len(families) == 0 {
+ return nil
+ }
+ slices.Sort(families)
+ return families
+}
+
+func normalizeKinds(values []resources.ResourceKind) []resources.ResourceKind {
+ if len(values) == 0 {
+ return nil
+ }
+
+ seen := make(map[resources.ResourceKind]struct{}, len(values))
+ kinds := make([]resources.ResourceKind, 0, len(values))
+ for _, value := range values {
+ normalized := value.Normalize()
+ if normalized == "" {
+ continue
+ }
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ kinds = append(kinds, normalized)
+ }
+ if len(kinds) == 0 {
+ return nil
+ }
+ slices.Sort(kinds)
+ return kinds
+}
+
+func scopesThrough(maxScope resources.ResourceScopeKind) []resources.ResourceScopeKind {
+ switch maxScope.Normalize() {
+ case resources.ResourceScopeKindGlobal:
+ return cloneScopes(globalAndWorkspaceScopes)
+ case resources.ResourceScopeKindWorkspace:
+ return []resources.ResourceScopeKind{resources.ResourceScopeKindWorkspace}
+ default:
+ return nil
+ }
+}
+
+func intersectScopes(
+ left []resources.ResourceScopeKind,
+ right []resources.ResourceScopeKind,
+) []resources.ResourceScopeKind {
+ if len(left) == 0 || len(right) == 0 {
+ return nil
+ }
+ index := make(map[resources.ResourceScopeKind]struct{}, len(right))
+ for _, scope := range right {
+ index[scope.Normalize()] = struct{}{}
+ }
+ var scopes []resources.ResourceScopeKind
+ for _, scope := range left {
+ normalized := scope.Normalize()
+ if _, ok := index[normalized]; ok {
+ scopes = append(scopes, normalized)
+ }
+ }
+ if len(scopes) == 0 {
+ return nil
+ }
+ slices.Sort(scopes)
+ return scopes
+}
+
+func cloneScopes(values []resources.ResourceScopeKind) []resources.ResourceScopeKind {
+ if len(values) == 0 {
+ return nil
+ }
+ return append([]resources.ResourceScopeKind(nil), values...)
+}
diff --git a/internal/extension/surfaces/registry_test.go b/internal/extension/surfaces/registry_test.go
new file mode 100644
index 000000000..7d8ede6f8
--- /dev/null
+++ b/internal/extension/surfaces/registry_test.go
@@ -0,0 +1,127 @@
+package surfaces
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+func TestLookupReturnsFirstWaveSurfaceMetadata(t *testing.T) {
+ t.Parallel()
+
+ surface, ok := Lookup(resources.ResourceKind("tool"))
+ if !ok {
+ t.Fatal("Lookup(tool) ok = false, want true")
+ }
+ if !surface.ExtensionPublish {
+ t.Fatal("Lookup(tool).ExtensionPublish = false, want true")
+ }
+ if surface.ManifestFamily != FamilyTools {
+ t.Fatalf("Lookup(tool).ManifestFamily = %q, want %q", surface.ManifestFamily, FamilyTools)
+ }
+ if !slices.Equal(surface.LegalScopes, []resources.ResourceScopeKind{
+ resources.ResourceScopeKindGlobal,
+ resources.ResourceScopeKindWorkspace,
+ }) {
+ t.Fatalf("Lookup(tool).LegalScopes = %#v, want global+workspace", surface.LegalScopes)
+ }
+}
+
+func TestResolveManifestRequestRejectsIllegalFamilyBeforeHandshake(t *testing.T) {
+ t.Parallel()
+
+ _, err := ResolveManifestRequest([]string{string(FamilyBridgeInstances)}, resources.ResourceScopeKindGlobal)
+ if err == nil {
+ t.Fatal("ResolveManifestRequest() error = nil, want daemon-only family rejection")
+ }
+}
+
+func TestResolveManifestRequestRejectsIllegalScopeBeforeHandshake(t *testing.T) {
+ t.Parallel()
+
+ _, err := ResolveManifestRequest([]string{string(FamilyTools)}, resources.ResourceScopeKind("session"))
+ if err == nil {
+ t.Fatal("ResolveManifestRequest() error = nil, want invalid scope rejection")
+ }
+}
+
+func TestNormalizeAllowedKindsRejectsDaemonOnlyKinds(t *testing.T) {
+ t.Parallel()
+
+ _, err := NormalizeAllowedKinds([]resources.ResourceKind{
+ resources.ResourceKind("tool"),
+ resources.ResourceKind("bridge.instance"),
+ })
+ if err == nil {
+ t.Fatal("NormalizeAllowedKinds() error = nil, want daemon-only rejection")
+ }
+}
+
+func TestResolveManifestRequestExpandsGlobalScopeToGrantedScopeSet(t *testing.T) {
+ t.Parallel()
+
+ request, err := ResolveManifestRequest(
+ []string{string(FamilyTools), string(FamilyMCPServers)},
+ resources.ResourceScopeKindGlobal,
+ )
+ if err != nil {
+ t.Fatalf("ResolveManifestRequest() error = %v", err)
+ }
+ if !slices.Equal(request.Kinds, []resources.ResourceKind{
+ resources.ResourceKind("mcp_server"),
+ resources.ResourceKind("tool"),
+ }) {
+ t.Fatalf("ResolveManifestRequest().Kinds = %#v, want tool+mcp_server", request.Kinds)
+ }
+ if !slices.Equal(request.Scopes, []resources.ResourceScopeKind{
+ resources.ResourceScopeKindGlobal,
+ resources.ResourceScopeKindWorkspace,
+ }) {
+ t.Fatalf("ResolveManifestRequest().Scopes = %#v, want global+workspace", request.Scopes)
+ }
+}
+
+func TestAllAndPublishableKindsReturnClonedStaticRegistry(t *testing.T) {
+ t.Parallel()
+
+ all := All()
+ if len(all) == 0 {
+ t.Fatal("All() = empty, want first-wave registry")
+ }
+ all[0].LegalScopes = nil
+
+ fetched, ok := Lookup(all[0].Kind)
+ if !ok {
+ t.Fatalf("Lookup(%q) ok = false, want true", all[0].Kind)
+ }
+ if len(fetched.LegalScopes) == 0 {
+ t.Fatal("Lookup().LegalScopes mutated through All() clone")
+ }
+
+ publishable := PublishableKinds()
+ if len(publishable) != 8 {
+ t.Fatalf("PublishableKinds() len = %d, want 8", len(publishable))
+ }
+}
+
+func TestResolveManifestRequestAllowsEmptyRequest(t *testing.T) {
+ t.Parallel()
+
+ request, err := ResolveManifestRequest(nil, "")
+ if err != nil {
+ t.Fatalf("ResolveManifestRequest(nil) error = %v", err)
+ }
+ if len(request.Kinds) != 0 || len(request.Scopes) != 0 || request.MaxScope != "" {
+ t.Fatalf("ResolveManifestRequest(nil) = %#v, want zero value", request)
+ }
+}
+
+func TestNormalizeAllowedKindsRejectsUnknownKinds(t *testing.T) {
+ t.Parallel()
+
+ _, err := NormalizeAllowedKinds([]resources.ResourceKind{resources.ResourceKind("unknown.kind")})
+ if err == nil {
+ t.Fatal("NormalizeAllowedKinds() error = nil, want unknown-kind rejection")
+ }
+}
diff --git a/internal/extension/telegram_reference_integration_test.go b/internal/extension/telegram_reference_integration_test.go
index d077682a6..cc066745a 100644
--- a/internal/extension/telegram_reference_integration_test.go
+++ b/internal/extension/telegram_reference_integration_test.go
@@ -235,15 +235,14 @@ func TestTelegramReferenceAdapterRestartResumesActiveDelivery(t *testing.T) {
t.Fatalf("ValidateConformance() error = %v", err)
}
- if len(deliveries) < 2 {
- t.Fatalf("len(deliveries) = %d, want at least 2", len(deliveries))
- }
resume := findDeliveryRecord(t, deliveries, bridgepkg.DeliveryEventTypeResume)
if resume.Request.Snapshot == nil {
t.Fatal("resume delivery snapshot = nil, want resumable state")
}
- if resume.PID == deliveries[0].PID {
- t.Fatalf("resume pid = %d, want a restarted adapter process different from %d", resume.PID, deliveries[0].PID)
+ // The crashed process can exit before its first delivery marker is flushed,
+ // so the stable restart proof is the resumed delivery plus its snapshot.
+ if resume.PID <= 0 {
+ t.Fatalf("resume pid = %d, want resumed delivery to record a live adapter process", resume.PID)
}
}
diff --git a/internal/extensiontest/bridge_adapter_harness.go b/internal/extensiontest/bridge_adapter_harness.go
index 23049c86d..5fa0d5a0c 100644
--- a/internal/extensiontest/bridge_adapter_harness.go
+++ b/internal/extensiontest/bridge_adapter_harness.go
@@ -17,6 +17,7 @@ import (
"github.com/pedronauck/agh/internal/acp"
bridgepkg "github.com/pedronauck/agh/internal/bridges"
aghconfig "github.com/pedronauck/agh/internal/config"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
extensionpkg "github.com/pedronauck/agh/internal/extension"
extensioncontract "github.com/pedronauck/agh/internal/extension/contract"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
@@ -867,6 +868,9 @@ func NewHarness(t testing.TB, cfg HarnessConfig) *Harness {
markers := NewMarkerPaths(filepath.Join(t.TempDir(), "markers"))
configureHarnessMarkers(t, markers, cfg)
workspace := defaultResolvedWorkspace(filepath.Join(t.TempDir(), "workspace"), now)
+ if err := os.MkdirAll(workspace.RootDir, 0o755); err != nil {
+ t.Fatalf("os.MkdirAll(%q) error = %v", workspace.RootDir, err)
+ }
workspaces := &staticWorkspaceResolver{resolved: workspace}
globalDB := openHarnessGlobalDB(t, homePaths, &workspace)
@@ -1252,6 +1256,10 @@ func newHarnessSessions(
) *session.Manager {
t.Helper()
+ environmentRegistry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
sessions, err := session.NewManager(
session.WithHomePaths(homePaths),
session.WithDriver(driver),
@@ -1263,6 +1271,7 @@ func newHarnessSessions(
session.WithNow(func() time.Time { return now }),
session.WithSessionIDGenerator(sequentialIDGenerator("sess")),
session.WithTurnIDGenerator(sequentialIDGenerator("turn")),
+ session.WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
diff --git a/internal/hooks/agent_event.go b/internal/hooks/agent_event.go
index 00cd229cf..4fc80a551 100644
--- a/internal/hooks/agent_event.go
+++ b/internal/hooks/agent_event.go
@@ -2,6 +2,6 @@ package hooks
import "context"
-// OnAgentEvent remains a no-op until the richer direct runtime integrations
-// land in the daemon/session wiring tasks.
-func (h *Hooks) OnAgentEvent(_ context.Context, _ string, _ any) {}
+// OnAgentEvent remains a compatibility no-op. The daemon translates streamed
+// ACP events into concrete tool and permission hook payloads before dispatch.
+func (h *Hooks) OnAgentEvent(_ context.Context, _ SessionContext, _ any) {}
diff --git a/internal/hooks/binding_state.go b/internal/hooks/binding_state.go
new file mode 100644
index 000000000..2238005d0
--- /dev/null
+++ b/internal/hooks/binding_state.go
@@ -0,0 +1,88 @@
+package hooks
+
+import (
+ "errors"
+ "fmt"
+)
+
+// BindingState captures one fully-built hook registry snapshot before it is
+// atomically swapped into the live runtime.
+type BindingState struct {
+ snapshot map[HookEvent][]*ResolvedHook
+ fingerprint string
+ hookCount int
+}
+
+// HookCount reports how many resolved hooks the binding state contains.
+func (s *BindingState) HookCount() int {
+ if s == nil {
+ return 0
+ }
+ return s.hookCount
+}
+
+// BuildBindingState validates declarations, binds executors, and computes the
+// next registry snapshot without mutating the live runtime.
+func (h *Hooks) BuildBindingState(decls []HookDecl) (*BindingState, error) {
+ if h == nil {
+ return nil, errors.New("hooks: dispatcher is required")
+ }
+
+ resolved, err := NormalizeHookDecls(decls, h.resolveExecutor)
+ if err != nil {
+ return nil, err
+ }
+
+ snapshot := buildHookSnapshot(resolved)
+ fingerprint, err := fingerprintHookSnapshot(snapshot)
+ if err != nil {
+ return nil, err
+ }
+
+ return &BindingState{
+ snapshot: snapshot,
+ fingerprint: fingerprint,
+ hookCount: countResolvedHooks(snapshot),
+ }, nil
+}
+
+// ApplyBindingState atomically swaps a previously-built binding snapshot into
+// the live runtime.
+func (h *Hooks) ApplyBindingState(state *BindingState, resourceRevision int64) error {
+ if h == nil {
+ return errors.New("hooks: dispatcher is required")
+ }
+ if state == nil {
+ return errors.New("hooks: binding state is required")
+ }
+ if resourceRevision < 0 {
+ return fmt.Errorf("hooks: resource revision cannot be negative: %d", resourceRevision)
+ }
+
+ reloadStarted := h.now()
+
+ h.mu.Lock()
+ if state.fingerprint == h.fingerprint {
+ h.mu.Unlock()
+ return nil
+ }
+
+ oldHookCount := countResolvedHooks(h.snapshot)
+ h.snapshot = state.snapshot
+ h.fingerprint = state.fingerprint
+ version := h.version.Add(1)
+ h.mu.Unlock()
+
+ reloadDuration := h.now().Sub(reloadStarted)
+ h.metrics.observeRegistryReload(reloadDuration, state.hookCount-oldHookCount)
+ h.logger.Info(
+ "hook.registry.projected",
+ "version", version,
+ "resource_revision", resourceRevision,
+ "hook_count", state.hookCount,
+ "hook_count_delta", state.hookCount-oldHookCount,
+ "duration_ms", reloadDuration.Milliseconds(),
+ )
+
+ return nil
+}
diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go
index c75750f50..e66d859e5 100644
--- a/internal/hooks/dispatch.go
+++ b/internal/hooks/dispatch.go
@@ -132,6 +132,97 @@ func (h *Hooks) DispatchSessionPostStop(
)
}
+// DispatchEnvironmentPrepare runs the environment.prepare hook pipeline.
+func (h *Hooks) DispatchEnvironmentPrepare(
+ ctx context.Context,
+ payload EnvironmentPreparePayload,
+) (EnvironmentPreparePayload, error) {
+ return executeDispatch(
+ ctx,
+ h,
+ HookEnvironmentPrepare,
+ payload,
+ dispatchConfig[EnvironmentPreparePayload, EnvironmentPreparePatch]{
+ match: matchEnvironmentPrepare,
+ apply: applyEnvironmentPreparePatch,
+ denied: environmentPreparePatchDenied,
+ denyErr: func(EnvironmentPreparePayload) error {
+ return fmt.Errorf("hooks: event %q denied", HookEnvironmentPrepare)
+ },
+ },
+ )
+}
+
+// DispatchEnvironmentReady runs the environment.ready hook dispatch.
+func (h *Hooks) DispatchEnvironmentReady(
+ ctx context.Context,
+ payload EnvironmentReadyPayload,
+) (EnvironmentReadyPayload, error) {
+ return executeDispatch(
+ ctx,
+ h,
+ HookEnvironmentReady,
+ payload,
+ dispatchConfig[EnvironmentReadyPayload, EnvironmentReadyPatch]{
+ match: matchEnvironmentReady,
+ apply: applyNoop[EnvironmentReadyPayload, EnvironmentReadyPatch],
+ },
+ )
+}
+
+// DispatchEnvironmentSyncBefore runs the environment.sync.before hook pipeline.
+func (h *Hooks) DispatchEnvironmentSyncBefore(
+ ctx context.Context,
+ payload EnvironmentSyncBeforePayload,
+) (EnvironmentSyncBeforePayload, error) {
+ return executeDispatch(
+ ctx,
+ h,
+ HookEnvironmentSyncBefore,
+ payload,
+ dispatchConfig[EnvironmentSyncBeforePayload, EnvironmentSyncBeforePatch]{
+ match: matchEnvironmentSyncBefore,
+ apply: applyEnvironmentSyncBeforePatch,
+ denied: environmentSyncBeforePatchDenied,
+ },
+ )
+}
+
+// DispatchEnvironmentSyncAfter runs the environment.sync.after hook dispatch.
+func (h *Hooks) DispatchEnvironmentSyncAfter(
+ ctx context.Context,
+ payload EnvironmentSyncAfterPayload,
+) (EnvironmentSyncAfterPayload, error) {
+ return executeDispatch(
+ ctx,
+ h,
+ HookEnvironmentSyncAfter,
+ payload,
+ dispatchConfig[EnvironmentSyncAfterPayload, EnvironmentSyncAfterPatch]{
+ match: matchEnvironmentSyncAfter,
+ apply: applyNoop[EnvironmentSyncAfterPayload, EnvironmentSyncAfterPatch],
+ },
+ )
+}
+
+// DispatchEnvironmentStop runs the environment.stop hook pipeline.
+func (h *Hooks) DispatchEnvironmentStop(
+ ctx context.Context,
+ payload EnvironmentStopPayload,
+) (EnvironmentStopPayload, error) {
+ return executeDispatch(
+ ctx,
+ h,
+ HookEnvironmentStop,
+ payload,
+ dispatchConfig[EnvironmentStopPayload, EnvironmentStopPatch]{
+ match: matchEnvironmentStop,
+ apply: applyEnvironmentStopPatch,
+ denied: environmentStopPatchDenied,
+ },
+ )
+}
+
// DispatchInputPreSubmit runs the input.pre_submit hook pipeline.
func (h *Hooks) DispatchInputPreSubmit(
ctx context.Context,
@@ -762,6 +853,42 @@ func applySessionLifecyclePatch(payload SessionLifecyclePayload, patch SessionCr
return payload
}
+func applyEnvironmentPreparePatch(
+ payload EnvironmentPreparePayload,
+ patch EnvironmentPreparePatch,
+) EnvironmentPreparePayload {
+ if patch.Deny {
+ payload.Denied = true
+ payload.DenyReason = patch.DenyReason
+ }
+ if patch.EnvOverrides != nil {
+ payload.EnvOverrides = cloneStringMap(patch.EnvOverrides)
+ }
+ return payload
+}
+
+func applyEnvironmentSyncBeforePatch(
+ payload EnvironmentSyncBeforePayload,
+ patch EnvironmentSyncBeforePatch,
+) EnvironmentSyncBeforePayload {
+ if patch.Deny {
+ payload.Denied = true
+ payload.DenyReason = patch.DenyReason
+ }
+ if patch.ExcludePatterns != nil {
+ payload.ExcludePatterns = append([]string(nil), patch.ExcludePatterns...)
+ }
+ return payload
+}
+
+func applyEnvironmentStopPatch(payload EnvironmentStopPayload, patch EnvironmentStopPatch) EnvironmentStopPayload {
+ if patch.Deny {
+ payload.Denied = true
+ payload.DenyReason = patch.DenyReason
+ }
+ return payload
+}
+
func applyInputPreSubmitPatch(payload InputPreSubmitPayload, patch InputPreSubmitPatch) InputPreSubmitPayload {
if patch.Message != nil {
payload.Message = *patch.Message
@@ -901,6 +1028,18 @@ func sessionCreatePatchDenied(patch SessionCreatePatch) bool {
return patch.Deny
}
+func environmentPreparePatchDenied(patch EnvironmentPreparePatch) bool {
+ return patch.Deny
+}
+
+func environmentSyncBeforePatchDenied(patch EnvironmentSyncBeforePatch) bool {
+ return patch.Deny
+}
+
+func environmentStopPatchDenied(patch EnvironmentStopPatch) bool {
+ return patch.Deny
+}
+
func inputPreSubmitPatchDenied(patch InputPreSubmitPatch) bool {
return patch.Deny
}
diff --git a/internal/hooks/events.go b/internal/hooks/events.go
index d04abdebd..c083b1747 100644
--- a/internal/hooks/events.go
+++ b/internal/hooks/events.go
@@ -6,23 +6,25 @@ import "fmt"
type HookEventFamily string
const (
- HookEventFamilySession HookEventFamily = "session"
- HookEventFamilyInput HookEventFamily = "input"
- HookEventFamilyPrompt HookEventFamily = "prompt"
- HookEventFamilyEvent HookEventFamily = "event"
- HookEventFamilyAutomation HookEventFamily = "automation"
- HookEventFamilyAgent HookEventFamily = "agent"
- HookEventFamilyTurn HookEventFamily = "turn"
- HookEventFamilyMessage HookEventFamily = "message"
- HookEventFamilyTool HookEventFamily = "tool"
- HookEventFamilyPermission HookEventFamily = "permission"
- HookEventFamilyContext HookEventFamily = "context"
+ HookEventFamilySession HookEventFamily = "session"
+ HookEventFamilyEnvironment HookEventFamily = "environment"
+ HookEventFamilyInput HookEventFamily = "input"
+ HookEventFamilyPrompt HookEventFamily = "prompt"
+ HookEventFamilyEvent HookEventFamily = "event"
+ HookEventFamilyAutomation HookEventFamily = "automation"
+ HookEventFamilyAgent HookEventFamily = "agent"
+ HookEventFamilyTurn HookEventFamily = "turn"
+ HookEventFamilyMessage HookEventFamily = "message"
+ HookEventFamilyTool HookEventFamily = "tool"
+ HookEventFamilyPermission HookEventFamily = "permission"
+ HookEventFamilyContext HookEventFamily = "context"
)
// Validate ensures the event family is part of the supported taxonomy.
func (f HookEventFamily) Validate() error {
switch f {
case HookEventFamilySession,
+ HookEventFamilyEnvironment,
HookEventFamilyInput,
HookEventFamilyPrompt,
HookEventFamilyEvent,
@@ -50,6 +52,12 @@ const (
HookSessionPreStop HookEvent = "session.pre_stop"
HookSessionPostStop HookEvent = "session.post_stop"
+ HookEnvironmentPrepare HookEvent = "environment.prepare"
+ HookEnvironmentReady HookEvent = "environment.ready"
+ HookEnvironmentSyncBefore HookEvent = "environment.sync.before"
+ HookEnvironmentSyncAfter HookEvent = "environment.sync.after"
+ HookEnvironmentStop HookEvent = "environment.stop"
+
HookInputPreSubmit HookEvent = "input.pre_submit"
HookPromptPostAssemble HookEvent = "prompt.post_assemble"
@@ -100,7 +108,27 @@ var hookEventSpecs = map[HookEvent]hookEventSpec{
HookSessionPostResume: {family: HookEventFamilySession, syncEligible: true},
HookSessionPreStop: {family: HookEventFamilySession, syncEligible: true},
HookSessionPostStop: {family: HookEventFamilySession, syncEligible: true},
- HookInputPreSubmit: {family: HookEventFamilyInput, syncEligible: true},
+ HookEnvironmentPrepare: {
+ family: HookEventFamilyEnvironment,
+ syncEligible: true,
+ },
+ HookEnvironmentReady: {
+ family: HookEventFamilyEnvironment,
+ syncEligible: false,
+ },
+ HookEnvironmentSyncBefore: {
+ family: HookEventFamilyEnvironment,
+ syncEligible: true,
+ },
+ HookEnvironmentSyncAfter: {
+ family: HookEventFamilyEnvironment,
+ syncEligible: false,
+ },
+ HookEnvironmentStop: {
+ family: HookEventFamilyEnvironment,
+ syncEligible: true,
+ },
+ HookInputPreSubmit: {family: HookEventFamilyInput, syncEligible: true},
HookPromptPostAssemble: {
family: HookEventFamilyPrompt,
syncEligible: true,
@@ -166,6 +194,11 @@ var allHookEvents = []HookEvent{
HookSessionPostResume,
HookSessionPreStop,
HookSessionPostStop,
+ HookEnvironmentPrepare,
+ HookEnvironmentReady,
+ HookEnvironmentSyncBefore,
+ HookEnvironmentSyncAfter,
+ HookEnvironmentStop,
HookInputPreSubmit,
HookPromptPostAssemble,
HookEventPreRecord,
diff --git a/internal/hooks/events_test.go b/internal/hooks/events_test.go
index 354dfdb4b..efe67a9d2 100644
--- a/internal/hooks/events_test.go
+++ b/internal/hooks/events_test.go
@@ -2,7 +2,7 @@ package hooks
import "testing"
-const expectedHookEventCount = 33
+const expectedHookEventCount = 38
func TestAllHookEvents(t *testing.T) {
t.Parallel()
@@ -39,6 +39,8 @@ func TestSyncEligibleClassification(t *testing.T) {
HookAutomationTriggerPostFire: {},
HookAutomationRunCompleted: {},
HookAutomationRunFailed: {},
+ HookEnvironmentReady: {},
+ HookEnvironmentSyncAfter: {},
HookPermissionResolved: {},
HookPermissionDenied: {},
}
diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go
index 4de70b447..9f523f5bf 100644
--- a/internal/hooks/hooks.go
+++ b/internal/hooks/hooks.go
@@ -266,42 +266,11 @@ func (h *Hooks) Rebuild(ctx context.Context) error {
return err
}
- resolved, err := NormalizeHookDecls(decls, h.resolveExecutor)
+ state, err := h.BuildBindingState(decls)
if err != nil {
return err
}
-
- snapshot := buildHookSnapshot(resolved)
- fingerprint, err := fingerprintHookSnapshot(snapshot)
- if err != nil {
- return err
- }
-
- reloadStarted := h.now()
- newHookCount := countResolvedHooks(snapshot)
-
- h.mu.Lock()
- defer h.mu.Unlock()
-
- if fingerprint == h.fingerprint {
- return nil
- }
-
- oldHookCount := countResolvedHooks(h.snapshot)
- h.snapshot = snapshot
- h.fingerprint = fingerprint
- version := h.version.Add(1)
- reloadDuration := h.now().Sub(reloadStarted)
- h.metrics.observeRegistryReload(reloadDuration, newHookCount-oldHookCount)
- h.logger.Info(
- "hook.registry.reloaded",
- "version", version,
- "hook_count", newHookCount,
- "hook_count_delta", newHookCount-oldHookCount,
- "duration_ms", reloadDuration.Milliseconds(),
- )
-
- return nil
+ return h.ApplyBindingState(state, 0)
}
func (h *Hooks) collectDeclarations(ctx context.Context) ([]HookDecl, error) {
@@ -426,6 +395,7 @@ func defaultExecutorResolver(decl HookDecl) (Executor, error) {
return NewSubprocessExecutor(
decl.Command,
decl.Args,
+ WithSubprocessDir(decl.WorkingDir),
WithSubprocessEnv(decl.Env),
), nil
case HookExecutorWASM:
diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go
index b1a47bf20..7f58be479 100644
--- a/internal/hooks/hooks_test.go
+++ b/internal/hooks/hooks_test.go
@@ -404,6 +404,56 @@ func TestDispatchMethodsSmokeNoHooks(t *testing.T) {
return err
},
},
+ {
+ name: "Should dispatch environment.prepare without hooks",
+ run: func(ctx context.Context, hooks *Hooks) error {
+ _, err := hooks.DispatchEnvironmentPrepare(
+ ctx,
+ EnvironmentPreparePayload{PayloadBase: PayloadBase{Event: HookEnvironmentPrepare}},
+ )
+ return err
+ },
+ },
+ {
+ name: "Should dispatch environment.ready without hooks",
+ run: func(ctx context.Context, hooks *Hooks) error {
+ _, err := hooks.DispatchEnvironmentReady(
+ ctx,
+ EnvironmentReadyPayload{PayloadBase: PayloadBase{Event: HookEnvironmentReady}},
+ )
+ return err
+ },
+ },
+ {
+ name: "Should dispatch environment.sync.before without hooks",
+ run: func(ctx context.Context, hooks *Hooks) error {
+ _, err := hooks.DispatchEnvironmentSyncBefore(
+ ctx,
+ EnvironmentSyncBeforePayload{PayloadBase: PayloadBase{Event: HookEnvironmentSyncBefore}},
+ )
+ return err
+ },
+ },
+ {
+ name: "Should dispatch environment.sync.after without hooks",
+ run: func(ctx context.Context, hooks *Hooks) error {
+ _, err := hooks.DispatchEnvironmentSyncAfter(
+ ctx,
+ EnvironmentSyncAfterPayload{PayloadBase: PayloadBase{Event: HookEnvironmentSyncAfter}},
+ )
+ return err
+ },
+ },
+ {
+ name: "Should dispatch environment.stop without hooks",
+ run: func(ctx context.Context, hooks *Hooks) error {
+ _, err := hooks.DispatchEnvironmentStop(
+ ctx,
+ EnvironmentStopPayload{PayloadBase: PayloadBase{Event: HookEnvironmentStop}},
+ )
+ return err
+ },
+ },
{
name: "Should dispatch input.pre_submit without hooks",
run: func(ctx context.Context, hooks *Hooks) error {
@@ -737,6 +787,145 @@ func TestDispatchSessionPreCreateAppliesPatch(t *testing.T) {
}
}
+func TestDispatchEnvironmentPrepareAppliesEnvOverridesAndDeny(t *testing.T) {
+ t.Parallel()
+
+ hooks := newTestHooks(
+ t,
+ WithNativeDeclarations([]HookDecl{{
+ Name: "environment-pre",
+ Event: HookEnvironmentPrepare,
+ Mode: HookModeSync,
+ ExecutorKind: HookExecutorNative,
+ Matcher: HookMatcher{
+ EnvironmentID: "env-1",
+ AgentName: "codex",
+ },
+ }}),
+ WithExecutorResolver(testExecutorResolver(map[string]Executor{
+ "environment-pre": NewTypedNativeExecutor(
+ func(_ context.Context, _ RegisteredHook, payload EnvironmentPreparePayload) (EnvironmentPreparePatch, error) {
+ if payload.EnvironmentID != "env-1" {
+ t.Fatalf("payload.EnvironmentID = %q, want env-1", payload.EnvironmentID)
+ }
+ return EnvironmentPreparePatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "policy"},
+ EnvOverrides: map[string]string{"SECRET_TOKEN": "redacted"},
+ }, nil
+ },
+ ),
+ })),
+ )
+ if err := hooks.Rebuild(t.Context()); err != nil {
+ t.Fatalf("Rebuild() error = %v, want nil", err)
+ }
+
+ result, err := hooks.DispatchEnvironmentPrepare(t.Context(), EnvironmentPreparePayload{
+ PayloadBase: PayloadBase{Event: HookEnvironmentPrepare},
+ SessionContext: SessionContext{
+ AgentName: "codex",
+ },
+ EnvironmentID: "env-1",
+ })
+ if err == nil {
+ t.Fatal("DispatchEnvironmentPrepare() error = nil, want deny error")
+ }
+ if !result.Denied || result.DenyReason != "policy" {
+ t.Fatalf("result deny fields = (%v, %q), want policy denial", result.Denied, result.DenyReason)
+ }
+ if got := result.EnvOverrides["SECRET_TOKEN"]; got != "redacted" {
+ t.Fatalf("result.EnvOverrides[SECRET_TOKEN] = %q, want redacted", got)
+ }
+}
+
+func TestDispatchEnvironmentSyncBeforeAppliesExcludePatternsAndDeny(t *testing.T) {
+ t.Parallel()
+
+ hooks := newTestHooks(
+ t,
+ WithNativeDeclarations([]HookDecl{{
+ Name: "environment-sync-before",
+ Event: HookEnvironmentSyncBefore,
+ Mode: HookModeSync,
+ ExecutorKind: HookExecutorNative,
+ Matcher: HookMatcher{
+ EnvironmentBackend: "daytona",
+ SyncDirection: "to_runtime",
+ },
+ }}),
+ WithExecutorResolver(testExecutorResolver(map[string]Executor{
+ "environment-sync-before": NewTypedNativeExecutor(
+ func(_ context.Context, _ RegisteredHook, _ EnvironmentSyncBeforePayload) (EnvironmentSyncBeforePatch, error) {
+ return EnvironmentSyncBeforePatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "maintenance"},
+ ExcludePatterns: []string{"node_modules/**", "*.log"},
+ }, nil
+ },
+ ),
+ })),
+ )
+ if err := hooks.Rebuild(t.Context()); err != nil {
+ t.Fatalf("Rebuild() error = %v, want nil", err)
+ }
+
+ result, err := hooks.DispatchEnvironmentSyncBefore(t.Context(), EnvironmentSyncBeforePayload{
+ PayloadBase: PayloadBase{Event: HookEnvironmentSyncBefore},
+ Backend: "daytona",
+ Direction: "to_runtime",
+ })
+ if err != nil {
+ t.Fatalf("DispatchEnvironmentSyncBefore() error = %v, want nil", err)
+ }
+ if !result.Denied || result.DenyReason != "maintenance" {
+ t.Fatalf("result deny fields = (%v, %q), want maintenance denial", result.Denied, result.DenyReason)
+ }
+ wantPatterns := []string{"node_modules/**", "*.log"}
+ if !reflect.DeepEqual(result.ExcludePatterns, wantPatterns) {
+ t.Fatalf("result.ExcludePatterns = %#v, want %#v", result.ExcludePatterns, wantPatterns)
+ }
+}
+
+func TestDispatchEnvironmentStopAppliesDeny(t *testing.T) {
+ t.Parallel()
+
+ hooks := newTestHooks(
+ t,
+ WithNativeDeclarations([]HookDecl{{
+ Name: "environment-stop",
+ Event: HookEnvironmentStop,
+ Mode: HookModeSync,
+ ExecutorKind: HookExecutorNative,
+ Matcher: HookMatcher{
+ EnvironmentID: "env-1",
+ },
+ }}),
+ WithExecutorResolver(testExecutorResolver(map[string]Executor{
+ "environment-stop": NewTypedNativeExecutor(
+ func(_ context.Context, _ RegisteredHook, _ EnvironmentStopPayload) (EnvironmentStopPatch, error) {
+ return EnvironmentStopPatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "retain for audit"},
+ }, nil
+ },
+ ),
+ })),
+ )
+ if err := hooks.Rebuild(t.Context()); err != nil {
+ t.Fatalf("Rebuild() error = %v, want nil", err)
+ }
+
+ result, err := hooks.DispatchEnvironmentStop(t.Context(), EnvironmentStopPayload{
+ PayloadBase: PayloadBase{Event: HookEnvironmentStop},
+ EnvironmentID: "env-1",
+ WillDestroy: true,
+ })
+ if err != nil {
+ t.Fatalf("DispatchEnvironmentStop() error = %v, want nil", err)
+ }
+ if !result.Denied || result.DenyReason != "retain for audit" {
+ t.Fatalf("result deny fields = (%v, %q), want retain denial", result.Denied, result.DenyReason)
+ }
+}
+
func TestDispatchPromptPostAssembleAppliesPatch(t *testing.T) {
t.Parallel()
@@ -1587,8 +1776,25 @@ func TestNewHooksAppliesOptionsAndDefaultResolver(t *testing.T) {
}); err == nil {
t.Fatal("defaultExecutorResolver(native) error = nil, want non-nil")
}
+ subprocessExecutor, err := defaultExecutorResolver(HookDecl{
+ Name: "subprocess-cwd",
+ ExecutorKind: HookExecutorSubprocess,
+ Command: "/bin/sh",
+ Args: []string{"-c", "true"},
+ WorkingDir: "/tmp/hook-cwd",
+ })
+ if err != nil {
+ t.Fatalf("defaultExecutorResolver(subprocess) error = %v, want nil", err)
+ }
+ resolvedSubprocess, ok := subprocessExecutor.(*SubprocessExecutor)
+ if !ok {
+ t.Fatalf("defaultExecutorResolver(subprocess) = %T, want *SubprocessExecutor", subprocessExecutor)
+ }
+ if got, want := resolvedSubprocess.dir, "/tmp/hook-cwd"; got != want {
+ t.Fatalf("subprocess executor dir = %q, want %q", got, want)
+ }
- hooks.OnAgentEvent(t.Context(), "session-id", struct{ Type string }{Type: "done"})
+ hooks.OnAgentEvent(t.Context(), SessionContext{SessionID: "session-id"}, struct{ Type string }{Type: "done"})
}
func newTestHooks(t *testing.T, opts ...Option) *Hooks {
diff --git a/internal/hooks/introspection.go b/internal/hooks/introspection.go
index f9606f689..555363a69 100644
--- a/internal/hooks/introspection.go
+++ b/internal/hooks/introspection.go
@@ -86,6 +86,41 @@ var hookEventDescriptors = map[HookEvent]EventDescriptor{
PayloadSchema: "SessionPostStopPayload",
PatchSchema: "SessionPostStopPatch",
},
+ HookEnvironmentPrepare: {
+ Event: HookEnvironmentPrepare,
+ Family: HookEventFamilyEnvironment,
+ SyncEligible: true,
+ PayloadSchema: "EnvironmentPreparePayload",
+ PatchSchema: "EnvironmentPreparePatch",
+ },
+ HookEnvironmentReady: {
+ Event: HookEnvironmentReady,
+ Family: HookEventFamilyEnvironment,
+ SyncEligible: false,
+ PayloadSchema: "EnvironmentReadyPayload",
+ PatchSchema: "EnvironmentReadyPatch",
+ },
+ HookEnvironmentSyncBefore: {
+ Event: HookEnvironmentSyncBefore,
+ Family: HookEventFamilyEnvironment,
+ SyncEligible: true,
+ PayloadSchema: "EnvironmentSyncBeforePayload",
+ PatchSchema: "EnvironmentSyncBeforePatch",
+ },
+ HookEnvironmentSyncAfter: {
+ Event: HookEnvironmentSyncAfter,
+ Family: HookEventFamilyEnvironment,
+ SyncEligible: false,
+ PayloadSchema: "EnvironmentSyncAfterPayload",
+ PatchSchema: "EnvironmentSyncAfterPatch",
+ },
+ HookEnvironmentStop: {
+ Event: HookEnvironmentStop,
+ Family: HookEventFamilyEnvironment,
+ SyncEligible: true,
+ PayloadSchema: "EnvironmentStopPayload",
+ PatchSchema: "EnvironmentStopPatch",
+ },
HookInputPreSubmit: {
Event: HookInputPreSubmit,
Family: HookEventFamilyInput,
diff --git a/internal/hooks/introspection_test.go b/internal/hooks/introspection_test.go
index 94208879c..9fc891583 100644
--- a/internal/hooks/introspection_test.go
+++ b/internal/hooks/introspection_test.go
@@ -105,6 +105,18 @@ func TestAllEventDescriptorsReturnsFullTaxonomy(t *testing.T) {
descriptor.PatchSchema != "AutomationObservationPatch" {
t.Fatalf("automation.run.failed descriptor = %#v, want automation async observation schema", descriptor)
}
+ if descriptor := byEvent[HookEnvironmentPrepare]; descriptor.Family != HookEventFamilyEnvironment ||
+ !descriptor.SyncEligible ||
+ descriptor.PayloadSchema != "EnvironmentPreparePayload" ||
+ descriptor.PatchSchema != "EnvironmentPreparePatch" {
+ t.Fatalf("environment.prepare descriptor = %#v, want sync environment prepare descriptor", descriptor)
+ }
+ if descriptor := byEvent[HookEnvironmentSyncAfter]; descriptor.Family != HookEventFamilyEnvironment ||
+ descriptor.SyncEligible ||
+ descriptor.PayloadSchema != "EnvironmentSyncAfterPayload" ||
+ descriptor.PatchSchema != "EnvironmentSyncAfterPatch" {
+ t.Fatalf("environment.sync.after descriptor = %#v, want async sync-after descriptor", descriptor)
+ }
}
func TestHooksCatalogFiltersByEventSourceModeAndExposesExecutorKind(t *testing.T) {
diff --git a/internal/hooks/matcher.go b/internal/hooks/matcher.go
index f28f8bd88..4fb03540a 100644
--- a/internal/hooks/matcher.go
+++ b/internal/hooks/matcher.go
@@ -16,6 +16,15 @@ var allowedMatcherFieldsByFamily = map[HookEventFamily]map[string]struct{}{
"workspace_root": {},
"session_type": {},
},
+ HookEventFamilyEnvironment: {
+ "agent_name": {},
+ "workspace_id": {},
+ "workspace_root": {},
+ "environment_id": {},
+ "environment_backend": {},
+ "environment_profile": {},
+ "sync_direction": {},
+ },
HookEventFamilyInput: {
"agent_name": {},
"workspace_id": {},
@@ -49,11 +58,17 @@ var allowedMatcherFieldsByFamily = map[HookEventFamily]map[string]struct{}{
"input_class": {},
},
HookEventFamilyTool: {
+ "agent_name": {},
+ "workspace_id": {},
+ "workspace_root": {},
"tool_name": {},
"tool_namespace": {},
"tool_read_only": {},
},
HookEventFamilyPermission: {
+ "agent_name": {},
+ "workspace_id": {},
+ "workspace_root": {},
"tool_name": {},
"decision_class": {},
},
@@ -100,6 +115,49 @@ func (m HookMatcher) MatchesSession(payload SessionContext) bool {
return m.matchSessionContext(payload, true)
}
+// MatchesEnvironmentPrepare matches environment prepare hooks.
+func (m HookMatcher) MatchesEnvironmentPrepare(payload EnvironmentPreparePayload) bool {
+ return m.matchEnvironment(
+ payload.SessionContext,
+ payload.EnvironmentID,
+ payload.Backend,
+ payload.Profile.Profile,
+ "",
+ )
+}
+
+// MatchesEnvironmentReady matches environment ready hooks.
+func (m HookMatcher) MatchesEnvironmentReady(payload EnvironmentReadyPayload) bool {
+ return m.matchEnvironment(payload.SessionContext, payload.EnvironmentID, payload.Backend, payload.Profile, "")
+}
+
+// MatchesEnvironmentSyncBefore matches environment pre-sync hooks.
+func (m HookMatcher) MatchesEnvironmentSyncBefore(payload EnvironmentSyncBeforePayload) bool {
+ return m.matchEnvironment(
+ payload.SessionContext,
+ payload.EnvironmentID,
+ payload.Backend,
+ payload.Profile,
+ payload.Direction,
+ )
+}
+
+// MatchesEnvironmentSyncAfter matches environment post-sync hooks.
+func (m HookMatcher) MatchesEnvironmentSyncAfter(payload EnvironmentSyncAfterPayload) bool {
+ return m.matchEnvironment(
+ payload.SessionContext,
+ payload.EnvironmentID,
+ payload.Backend,
+ payload.Profile,
+ payload.Direction,
+ )
+}
+
+// MatchesEnvironmentStop matches environment stop hooks.
+func (m HookMatcher) MatchesEnvironmentStop(payload EnvironmentStopPayload) bool {
+ return m.matchEnvironment(payload.SessionContext, payload.EnvironmentID, payload.Backend, payload.Profile, "")
+}
+
// MatchesInput matches input-family hooks.
func (m HookMatcher) MatchesInput(payload InputPreSubmitPayload) bool {
return m.matchSessionContext(payload.SessionContext, false) &&
@@ -149,27 +207,32 @@ func (m HookMatcher) MatchesMessage(payload MessagePayload) bool {
// MatchesToolPreCall matches tool pre-call hooks.
func (m HookMatcher) MatchesToolPreCall(payload ToolPreCallPayload) bool {
- return m.matchToolCall(payload.ToolCallRef)
+ return m.matchSessionContext(payload.SessionContext, false) &&
+ m.matchToolCall(payload.ToolCallRef)
}
// MatchesToolPostCall matches tool post-call hooks.
func (m HookMatcher) MatchesToolPostCall(payload ToolPostCallPayload) bool {
- return m.matchToolCall(payload.ToolCallRef)
+ return m.matchSessionContext(payload.SessionContext, false) &&
+ m.matchToolCall(payload.ToolCallRef)
}
// MatchesToolPostError matches tool post-error hooks.
func (m HookMatcher) MatchesToolPostError(payload ToolPostErrorPayload) bool {
- return m.matchToolCall(payload.ToolCallRef)
+ return m.matchSessionContext(payload.SessionContext, false) &&
+ m.matchToolCall(payload.ToolCallRef)
}
// MatchesPermissionRequest matches permission-request hooks.
func (m HookMatcher) MatchesPermissionRequest(payload PermissionRequestPayload) bool {
- return m.matchPermission(payload.ToolCall.Kind, payload.DecisionClass)
+ return m.matchSessionContext(payload.SessionContext, false) &&
+ m.matchPermission(payload.ToolCall.Kind, payload.DecisionClass)
}
// MatchesPermissionResolution matches resolved and denied permission hooks.
func (m HookMatcher) MatchesPermissionResolution(payload PermissionResolutionPayload) bool {
- return m.matchPermission(payload.ToolCall.Kind, payload.DecisionClass)
+ return m.matchSessionContext(payload.SessionContext, false) &&
+ m.matchPermission(payload.ToolCall.Kind, payload.DecisionClass)
}
// MatchesContextCompact matches context-compaction hooks.
@@ -212,6 +275,26 @@ func matchSessionLifecycle(matcher HookMatcher, payload SessionLifecyclePayload)
return matcher.MatchesSession(payload.SessionContext)
}
+func matchEnvironmentPrepare(matcher HookMatcher, payload EnvironmentPreparePayload) bool {
+ return matcher.MatchesEnvironmentPrepare(payload)
+}
+
+func matchEnvironmentReady(matcher HookMatcher, payload EnvironmentReadyPayload) bool {
+ return matcher.MatchesEnvironmentReady(payload)
+}
+
+func matchEnvironmentSyncBefore(matcher HookMatcher, payload EnvironmentSyncBeforePayload) bool {
+ return matcher.MatchesEnvironmentSyncBefore(payload)
+}
+
+func matchEnvironmentSyncAfter(matcher HookMatcher, payload EnvironmentSyncAfterPayload) bool {
+ return matcher.MatchesEnvironmentSyncAfter(payload)
+}
+
+func matchEnvironmentStop(matcher HookMatcher, payload EnvironmentStopPayload) bool {
+ return matcher.MatchesEnvironmentStop(payload)
+}
+
func matchInputPreSubmit(matcher HookMatcher, payload InputPreSubmitPayload) bool {
return matcher.MatchesInput(payload)
}
@@ -304,6 +387,20 @@ func (m HookMatcher) matchSessionContext(payload SessionContext, includeSessionT
return true
}
+func (m HookMatcher) matchEnvironment(
+ session SessionContext,
+ environmentID string,
+ backend string,
+ profile string,
+ direction string,
+) bool {
+ return m.matchSessionContext(session, false) &&
+ matchStringField(m.EnvironmentID, environmentID) &&
+ matchStringField(m.EnvironmentBackend, backend) &&
+ matchStringField(m.EnvironmentProfile, profile) &&
+ matchStringField(m.SyncDirection, direction)
+}
+
func (m HookMatcher) matchToolCall(payload ToolCallRef) bool {
if !matchStringField(m.ToolName, payload.ToolName) {
return false
@@ -329,6 +426,10 @@ func normalizeHookMatcher(matcher HookMatcher) HookMatcher {
WorkspaceID: strings.TrimSpace(matcher.WorkspaceID),
WorkspaceRoot: strings.TrimSpace(matcher.WorkspaceRoot),
SessionType: strings.TrimSpace(matcher.SessionType),
+ EnvironmentID: strings.TrimSpace(matcher.EnvironmentID),
+ EnvironmentBackend: strings.TrimSpace(matcher.EnvironmentBackend),
+ EnvironmentProfile: strings.TrimSpace(matcher.EnvironmentProfile),
+ SyncDirection: strings.TrimSpace(matcher.SyncDirection),
InputClass: strings.TrimSpace(matcher.InputClass),
ACPEventType: strings.TrimSpace(matcher.ACPEventType),
TurnID: strings.TrimSpace(matcher.TurnID),
@@ -361,6 +462,10 @@ func matcherFieldNames(matcher HookMatcher) []string {
appendIf("workspace_id", matcher.WorkspaceID != "")
appendIf("workspace_root", matcher.WorkspaceRoot != "")
appendIf("session_type", matcher.SessionType != "")
+ appendIf("environment_id", matcher.EnvironmentID != "")
+ appendIf("environment_backend", matcher.EnvironmentBackend != "")
+ appendIf("environment_profile", matcher.EnvironmentProfile != "")
+ appendIf("sync_direction", matcher.SyncDirection != "")
appendIf("input_class", matcher.InputClass != "")
appendIf("acp_event_type", matcher.ACPEventType != "")
appendIf("turn_id", matcher.TurnID != "")
@@ -386,6 +491,10 @@ func validateMatcherPatterns(matcher HookMatcher) error {
{field: "workspace_id", pattern: matcher.WorkspaceID},
{field: "workspace_root", pattern: matcher.WorkspaceRoot},
{field: "session_type", pattern: matcher.SessionType},
+ {field: "environment_id", pattern: matcher.EnvironmentID},
+ {field: "environment_backend", pattern: matcher.EnvironmentBackend},
+ {field: "environment_profile", pattern: matcher.EnvironmentProfile},
+ {field: "sync_direction", pattern: matcher.SyncDirection},
{field: "input_class", pattern: matcher.InputClass},
{field: "acp_event_type", pattern: matcher.ACPEventType},
{field: "turn_id", pattern: matcher.TurnID},
diff --git a/internal/hooks/matcher_test.go b/internal/hooks/matcher_test.go
index c018fca83..9fb320f3b 100644
--- a/internal/hooks/matcher_test.go
+++ b/internal/hooks/matcher_test.go
@@ -238,6 +238,66 @@ func TestHookMatcherMatchesAutomation(t *testing.T) {
}
}
+func TestHookMatcherMatchesEnvironment(t *testing.T) {
+ t.Parallel()
+
+ prepareMatcher := HookMatcher{
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ EnvironmentID: "env-1",
+ EnvironmentBackend: "daytona",
+ EnvironmentProfile: "daytona-dev",
+ }
+ if !prepareMatcher.MatchesEnvironmentPrepare(EnvironmentPreparePayload{
+ SessionContext: SessionContext{
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ },
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: EnvironmentProfilePayload{Profile: "daytona-dev"},
+ }) {
+ t.Fatal("MatchesEnvironmentPrepare() = false, want true")
+ }
+ syncMatcher := prepareMatcher
+ syncMatcher.SyncDirection = "to_runtime"
+ if !syncMatcher.MatchesEnvironmentSyncBefore(EnvironmentSyncBeforePayload{
+ SessionContext: SessionContext{
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ },
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ Direction: "to_runtime",
+ }) {
+ t.Fatal("MatchesEnvironmentSyncBefore() = false, want true")
+ }
+ if syncMatcher.MatchesEnvironmentSyncAfter(EnvironmentSyncAfterPayload{
+ SessionContext: SessionContext{
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ },
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ Direction: "from_runtime",
+ }) {
+ t.Fatal("MatchesEnvironmentSyncAfter() = true, want false for direction mismatch")
+ }
+ if prepareMatcher.MatchesEnvironmentStop(EnvironmentStopPayload{
+ SessionContext: SessionContext{
+ AgentName: "codex",
+ WorkspaceID: "ws-1",
+ },
+ EnvironmentID: "env-2",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ }) {
+ t.Fatal("MatchesEnvironmentStop() = true, want false for environment id mismatch")
+ }
+}
+
func TestHookMatcherMatchesToolResponses(t *testing.T) {
t.Parallel()
diff --git a/internal/hooks/normalize.go b/internal/hooks/normalize.go
index 98d09fff0..7a39cdd5f 100644
--- a/internal/hooks/normalize.go
+++ b/internal/hooks/normalize.go
@@ -21,6 +21,12 @@ func ValidateHookDecl(decl HookDecl) error {
return err
}
+// CanonicalizeHookDecl validates one declaration and applies defaults without
+// binding an executor.
+func CanonicalizeHookDecl(decl HookDecl) (HookDecl, error) {
+ return sanitizedHookDecl(decl)
+}
+
// ValidateHookDecls validates a declaration slice and stops at the first error.
func ValidateHookDecls(decls []HookDecl) error {
for idx, decl := range decls {
diff --git a/internal/hooks/payloads.go b/internal/hooks/payloads.go
index ad707a183..abae453dc 100644
--- a/internal/hooks/payloads.go
+++ b/internal/hooks/payloads.go
@@ -133,6 +133,122 @@ type SessionPreStopPatch = SessionCreatePatch
// SessionPostStopPatch is the post-stop patch surface.
type SessionPostStopPatch = SessionCreatePatch
+// EnvironmentProfilePayload is the environment profile snapshot exposed to environment hooks.
+type EnvironmentProfilePayload struct {
+ Profile string `json:"profile,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ SyncMode string `json:"sync_mode,omitempty"`
+ Persistence string `json:"persistence,omitempty"`
+ RuntimeRootDir string `json:"runtime_root,omitempty"`
+ DestroyOnStop bool `json:"destroy_on_stop,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+}
+
+// EnvironmentPreparePayload is delivered before a session environment is prepared.
+type EnvironmentPreparePayload struct {
+ PayloadBase
+ SessionContext
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile EnvironmentProfilePayload `json:"profile"`
+ LocalRootDir string `json:"local_root,omitempty"`
+ LocalAdditionalDirs []string `json:"local_additional_dirs,omitempty"`
+ AgentCommand string `json:"agent_command,omitempty"`
+ AgentEnv []string `json:"agent_env,omitempty"`
+ Permissions string `json:"permissions,omitempty"`
+ ResumeACPState string `json:"resume_acp_state,omitempty"`
+ EnvOverrides map[string]string `json:"env_overrides,omitempty"`
+ Denied bool `json:"denied,omitempty"`
+ DenyReason string `json:"deny_reason,omitempty"`
+}
+
+// EnvironmentReadyPayload is delivered after an environment has been prepared and synchronized.
+type EnvironmentReadyPayload struct {
+ PayloadBase
+ SessionContext
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ RuntimeRootDir string `json:"runtime_root,omitempty"`
+ RuntimeAdditionalDirs []string `json:"runtime_additional_dirs,omitempty"`
+}
+
+// EnvironmentSyncBeforePayload is delivered before an environment sync operation runs.
+type EnvironmentSyncBeforePayload struct {
+ PayloadBase
+ SessionContext
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ RuntimeRootDir string `json:"runtime_root,omitempty"`
+ Direction string `json:"direction,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ FileCount int `json:"file_count,omitempty"`
+ ExcludePatterns []string `json:"exclude_patterns,omitempty"`
+ Denied bool `json:"denied,omitempty"`
+ DenyReason string `json:"deny_reason,omitempty"`
+}
+
+// EnvironmentSyncAfterPayload is delivered after an environment sync operation finishes.
+type EnvironmentSyncAfterPayload struct {
+ PayloadBase
+ SessionContext
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ RuntimeRootDir string `json:"runtime_root,omitempty"`
+ Direction string `json:"direction,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ FilesSynced int `json:"files_synced,omitempty"`
+ BytesTransferred int64 `json:"bytes_transferred,omitempty"`
+ DurationMS int64 `json:"duration_ms,omitempty"`
+ Errors []string `json:"errors,omitempty"`
+}
+
+// EnvironmentStopPayload is delivered before environment teardown.
+type EnvironmentStopPayload struct {
+ PayloadBase
+ SessionContext
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ RuntimeRootDir string `json:"runtime_root,omitempty"`
+ StopReason string `json:"stop_reason,omitempty"`
+ WillDestroy bool `json:"will_destroy,omitempty"`
+ Denied bool `json:"denied,omitempty"`
+ DenyReason string `json:"deny_reason,omitempty"`
+}
+
+// EnvironmentPreparePatch mutates or denies environment preparation.
+type EnvironmentPreparePatch struct {
+ ControlPatch
+ EnvOverrides map[string]string `json:"env_overrides,omitempty"`
+}
+
+// EnvironmentSyncBeforePatch mutates or denies environment sync.
+type EnvironmentSyncBeforePatch struct {
+ ControlPatch
+ ExcludePatterns []string `json:"exclude_patterns,omitempty"`
+}
+
+// EnvironmentObservationPatch is the no-op patch surface for environment observation hooks.
+type EnvironmentObservationPatch struct{}
+
+// EnvironmentReadyPatch is the ready patch surface.
+type EnvironmentReadyPatch = EnvironmentObservationPatch
+
+// EnvironmentSyncAfterPatch is the sync-after patch surface.
+type EnvironmentSyncAfterPatch = EnvironmentObservationPatch
+
+// EnvironmentStopPatch mutates or denies environment teardown.
+type EnvironmentStopPatch struct {
+ ControlPatch
+}
+
// InputPreSubmitPayload is delivered before prompt submission.
type InputPreSubmitPayload struct {
PayloadBase
@@ -539,6 +655,26 @@ func (p SessionLifecyclePayload) hookSessionContext() SessionContext {
return p.SessionContext
}
+func (p EnvironmentPreparePayload) hookSessionContext() SessionContext {
+ return p.SessionContext
+}
+
+func (p EnvironmentReadyPayload) hookSessionContext() SessionContext {
+ return p.SessionContext
+}
+
+func (p EnvironmentSyncBeforePayload) hookSessionContext() SessionContext {
+ return p.SessionContext
+}
+
+func (p EnvironmentSyncAfterPayload) hookSessionContext() SessionContext {
+ return p.SessionContext
+}
+
+func (p EnvironmentStopPayload) hookSessionContext() SessionContext {
+ return p.SessionContext
+}
+
func (p InputPreSubmitPayload) hookSessionContext() SessionContext {
return p.SessionContext
}
diff --git a/internal/hooks/payloads_test.go b/internal/hooks/payloads_test.go
index 38e2f02ad..02a61e65c 100644
--- a/internal/hooks/payloads_test.go
+++ b/internal/hooks/payloads_test.go
@@ -102,6 +102,95 @@ func TestPayloadsAndPatchesJSONRoundTrip(t *testing.T) {
Workspace: &workspace,
})
+ assertJSONRoundTrip(t, "EnvironmentPreparePayload", EnvironmentPreparePayload{
+ PayloadBase: samplePayloadBase(HookEnvironmentPrepare),
+ SessionContext: sampleSession,
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: EnvironmentProfilePayload{
+ Profile: "daytona-dev",
+ Backend: "daytona",
+ SyncMode: "session-bidirectional",
+ Persistence: "transient",
+ RuntimeRootDir: "/workspace",
+ DestroyOnStop: true,
+ Env: map[string]string{"BASE": "1"},
+ },
+ LocalRootDir: "/local",
+ LocalAdditionalDirs: []string{"/local-extra"},
+ AgentCommand: "codex",
+ AgentEnv: []string{"BASE=1"},
+ Permissions: "approve-all",
+ ResumeACPState: "acp-1",
+ EnvOverrides: map[string]string{"SECRET": "token"},
+ Denied: true,
+ DenyReason: "policy",
+ })
+ assertJSONRoundTrip(t, "EnvironmentReadyPayload", EnvironmentReadyPayload{
+ PayloadBase: samplePayloadBase(HookEnvironmentReady),
+ SessionContext: sampleSession,
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ InstanceID: "instance-1",
+ RuntimeRootDir: "/runtime",
+ RuntimeAdditionalDirs: []string{"/runtime-extra"},
+ })
+ assertJSONRoundTrip(t, "EnvironmentSyncBeforePayload", EnvironmentSyncBeforePayload{
+ PayloadBase: samplePayloadBase(HookEnvironmentSyncBefore),
+ SessionContext: sampleSession,
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ InstanceID: "instance-1",
+ RuntimeRootDir: "/runtime",
+ Direction: "to_runtime",
+ Reason: "start",
+ FileCount: 3,
+ ExcludePatterns: []string{"node_modules/**"},
+ Denied: true,
+ DenyReason: "blocked",
+ })
+ assertJSONRoundTrip(t, "EnvironmentSyncAfterPayload", EnvironmentSyncAfterPayload{
+ PayloadBase: samplePayloadBase(HookEnvironmentSyncAfter),
+ SessionContext: sampleSession,
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ InstanceID: "instance-1",
+ RuntimeRootDir: "/runtime",
+ Direction: "from_runtime",
+ Reason: "stop",
+ FilesSynced: 5,
+ BytesTransferred: 4096,
+ DurationMS: 37,
+ Errors: []string{"retryable warning"},
+ })
+ assertJSONRoundTrip(t, "EnvironmentStopPayload", EnvironmentStopPayload{
+ PayloadBase: samplePayloadBase(HookEnvironmentStop),
+ SessionContext: sampleSession,
+ EnvironmentID: "env-1",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ InstanceID: "instance-1",
+ RuntimeRootDir: "/runtime",
+ StopReason: "user_requested",
+ WillDestroy: true,
+ Denied: true,
+ DenyReason: "retain",
+ })
+ assertJSONRoundTrip(t, "EnvironmentPreparePatch", EnvironmentPreparePatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "policy"},
+ EnvOverrides: map[string]string{"SECRET": "token"},
+ })
+ assertJSONRoundTrip(t, "EnvironmentSyncBeforePatch", EnvironmentSyncBeforePatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "sync blocked"},
+ ExcludePatterns: []string{"tmp/**"},
+ })
+ assertJSONRoundTrip(t, "EnvironmentStopPatch", EnvironmentStopPatch{
+ ControlPatch: ControlPatch{Deny: true, DenyReason: "retain"},
+ })
+
assertJSONRoundTrip(t, "InputPreSubmitPayload", InputPreSubmitPayload{
PayloadBase: samplePayloadBase(HookInputPreSubmit),
SessionContext: sampleSession,
diff --git a/internal/hooks/types.go b/internal/hooks/types.go
index 65e3636e3..dcef1a1aa 100644
--- a/internal/hooks/types.go
+++ b/internal/hooks/types.go
@@ -142,6 +142,10 @@ type HookMatcher struct {
WorkspaceID string `json:"workspace_id,omitempty" yaml:"workspace_id,omitempty"`
WorkspaceRoot string `json:"workspace_root,omitempty" yaml:"workspace_root,omitempty"`
SessionType string `json:"session_type,omitempty" yaml:"session_type,omitempty"`
+ EnvironmentID string `json:"environment_id,omitempty" yaml:"environment_id,omitempty"`
+ EnvironmentBackend string `json:"environment_backend,omitempty" yaml:"environment_backend,omitempty"`
+ EnvironmentProfile string `json:"environment_profile,omitempty" yaml:"environment_profile,omitempty"`
+ SyncDirection string `json:"sync_direction,omitempty" yaml:"sync_direction,omitempty"`
InputClass string `json:"input_class,omitempty" yaml:"input_class,omitempty"`
ACPEventType string `json:"acp_event_type,omitempty" yaml:"acp_event_type,omitempty"`
TurnID string `json:"turn_id,omitempty" yaml:"turn_id,omitempty"`
diff --git a/internal/network/delivery_integration_test.go b/internal/network/delivery_integration_test.go
index a6ccc8640..8bd0de465 100644
--- a/internal/network/delivery_integration_test.go
+++ b/internal/network/delivery_integration_test.go
@@ -16,6 +16,7 @@ import (
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ environmentlocal "github.com/pedronauck/agh/internal/environment/local"
"github.com/pedronauck/agh/internal/session"
"github.com/pedronauck/agh/internal/testutil"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
@@ -201,11 +202,16 @@ func newDeliveryIntegrationHarness(t *testing.T) (*session.Manager, *integration
}
driver := newIntegrationPromptDriver()
+ environmentRegistry, err := environmentlocal.NewRegistry()
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
manager, err := session.NewManager(
session.WithHomePaths(homePaths),
session.WithWorkspaceResolver(resolver),
session.WithDriver(driver),
session.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
+ session.WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
diff --git a/internal/network/tasks.go b/internal/network/tasks.go
index 2b3e4154d..1b7ef8376 100644
--- a/internal/network/tasks.go
+++ b/internal/network/tasks.go
@@ -12,6 +12,7 @@ import (
const (
networkTaskWriteCapability = "task.write"
taskIngressReasonChannelMismatch = "channel_mismatch"
+ taskIngressReasonStaleChannel = "stale_channel"
networkTaskActionCreate = "task.create"
networkTaskActionUpdate = "task.update"
@@ -436,7 +437,7 @@ func taskIngressReason(err error) string {
case errors.Is(err, ErrTaskChannelMismatch):
return taskIngressReasonChannelMismatch
case errors.Is(err, ErrTaskChannelStale):
- return "stale_channel"
+ return taskIngressReasonStaleChannel
case errors.Is(err, ErrTaskIngressCapabilityDenied):
return "capability_denied"
case errors.Is(err, ErrTaskIngressPeerNotFound):
@@ -450,7 +451,7 @@ func taskIngressReason(err error) string {
case errors.Is(err, taskpkg.ErrPermissionDenied):
return "permission_denied"
case errors.Is(err, taskpkg.ErrStaleNetworkChannel):
- return "stale_channel"
+ return taskIngressReasonStaleChannel
case errors.Is(err, ErrMissingField), errors.Is(err, ErrInvalidField):
return "invalid_request"
default:
diff --git a/internal/observe/observer.go b/internal/observe/observer.go
index 70ace9dde..b93d32bff 100644
--- a/internal/observe/observer.go
+++ b/internal/observe/observer.go
@@ -385,6 +385,7 @@ func (o *Observer) OnSessionStopped(ctx context.Context, sess *session.Session)
StopReasonSet: true,
StopReason: stringPointer(string(info.StopReason)),
StopDetail: info.StopDetail,
+ Environment: cloneSessionEnvironmentMeta(info.Environment),
UpdatedAt: info.UpdatedAt,
}); err != nil {
o.logger.Warn(
@@ -709,11 +710,33 @@ func sessionInfoFromSession(info *session.Info) store.SessionInfo {
ACPSessionID: stringPointer(info.ACPSessionID),
StopReason: info.StopReason,
StopDetail: info.StopDetail,
+ Environment: cloneSessionEnvironmentMeta(info.Environment),
CreatedAt: info.CreatedAt,
UpdatedAt: info.UpdatedAt,
}
}
+// OnEnvironmentLifecycleEvent receives optional environment lifecycle spans from session orchestration.
+func (o *Observer) OnEnvironmentLifecycleEvent(_ context.Context, event session.EnvironmentLifecycleEvent) {
+ if o == nil || o.logger == nil {
+ return
+ }
+ o.logger.Debug(
+ "observe: environment lifecycle",
+ "name", event.Name,
+ "span", event.Span,
+ "session_id", event.SessionID,
+ "workspace_id", event.WorkspaceID,
+ "environment_id", event.EnvironmentID,
+ "backend", event.Backend,
+ "profile", event.Profile,
+ "instance_id", event.InstanceID,
+ "duration_ms", event.Duration.Milliseconds(),
+ "error_kind", event.ErrorKind,
+ "error", event.Error,
+ )
+}
+
func summarizeEvent(event acp.AgentEvent) string {
candidates := []string{
strings.TrimSpace(event.Text),
@@ -759,6 +782,26 @@ func truncateSummary(summary string) string {
return string(runes[:maxRunes-3]) + "..."
}
+func cloneSessionEnvironmentMeta(meta *store.SessionEnvironmentMeta) *store.SessionEnvironmentMeta {
+ if meta == nil {
+ return nil
+ }
+ cloned := *meta
+ cloned.RuntimeAdditionalDirs = append([]string(nil), meta.RuntimeAdditionalDirs...)
+ if meta.ProviderState != nil {
+ cloned.ProviderState = append([]byte(nil), meta.ProviderState...)
+ }
+ if meta.SSHAccessExpiresAt != nil {
+ expiresAt := *meta.SSHAccessExpiresAt
+ cloned.SSHAccessExpiresAt = &expiresAt
+ }
+ if meta.LastSyncAt != nil {
+ lastSyncAt := *meta.LastSyncAt
+ cloned.LastSyncAt = &lastSyncAt
+ }
+ return &cloned
+}
+
func shouldAggregateUsage(event acp.AgentEvent) bool {
return strings.TrimSpace(event.Type) == acp.EventTypeDone && event.Usage != nil && !event.Usage.IsZero()
}
diff --git a/internal/observe/reconcile.go b/internal/observe/reconcile.go
index 25fadfb23..c16b47ee6 100644
--- a/internal/observe/reconcile.go
+++ b/internal/observe/reconcile.go
@@ -78,6 +78,7 @@ func (o *Observer) loadSessionMetadata() ([]store.SessionInfo, error) {
ACPSessionID: normalized.ACPSessionID,
StopReason: stopReason,
StopDetail: normalized.StopDetail,
+ Environment: cloneSessionEnvironmentMeta(normalized.Environment),
CreatedAt: normalized.CreatedAt,
UpdatedAt: normalized.UpdatedAt,
})
diff --git a/internal/registry/clawhub/client.go b/internal/registry/clawhub/client.go
index 2a70f6cff..3e02b2892 100644
--- a/internal/registry/clawhub/client.go
+++ b/internal/registry/clawhub/client.go
@@ -19,6 +19,7 @@ import (
)
const (
+ sourceName = "clawhub"
defaultBaseURL = "https://clawhub.ai/api/v1"
defaultRequestTimeout = 30 * time.Second
defaultInitialBackoff = time.Second
@@ -112,7 +113,7 @@ func WithRetryPolicy(initial, maxDelay time.Duration, retries int) Option {
// Name reports the registry source name.
func (c *Client) Name() string {
- return "clawhub"
+ return sourceName
}
// Capabilities reports which registry operations ClawHub supports.
diff --git a/internal/resources/codec.go b/internal/resources/codec.go
new file mode 100644
index 000000000..6bf575103
--- /dev/null
+++ b/internal/resources/codec.go
@@ -0,0 +1,260 @@
+package resources
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "sync"
+)
+
+// KindCodec owns the typed encode/decode boundary for one resource kind.
+type KindCodec[T any] interface {
+ Kind() ResourceKind
+ DecodeAndValidate(ctx context.Context, scope ResourceScope, raw []byte) (T, error)
+ Encode(spec T) ([]byte, error)
+ MaxBytes() int
+}
+
+// SpecValidator enforces typed invariants after decoding and before persistence.
+type SpecValidator[T any] func(ctx context.Context, scope ResourceScope, spec T) (T, error)
+
+type jsonCodec[T any] struct {
+ kind ResourceKind
+ maxBytes int
+ validator SpecValidator[T]
+}
+
+type rawSpecCodec interface {
+ Kind() ResourceKind
+ MaxBytes() int
+ ValidateAndCanonicalizeRaw(ctx context.Context, scope ResourceScope, raw []byte) ([]byte, error)
+}
+
+// NewJSONCodec builds a JSON-backed codec with a typed validation hook.
+func NewJSONCodec[T any](kind ResourceKind, maxBytes int, validator SpecValidator[T]) (KindCodec[T], error) {
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("codec.kind"); err != nil {
+ return nil, err
+ }
+ if maxBytes <= 0 {
+ return nil, fmt.Errorf("%w: codec.max_bytes must be positive: %d", ErrValidation, maxBytes)
+ }
+
+ return &jsonCodec[T]{
+ kind: normalizedKind,
+ maxBytes: maxBytes,
+ validator: validator,
+ }, nil
+}
+
+func (c *jsonCodec[T]) Kind() ResourceKind {
+ return c.kind
+}
+
+func (c *jsonCodec[T]) MaxBytes() int {
+ return c.maxBytes
+}
+
+func (c *jsonCodec[T]) Encode(spec T) ([]byte, error) {
+ encoded, err := json.Marshal(spec)
+ if err != nil {
+ return nil, fmt.Errorf("resources: encode %q spec: %w", c.kind, err)
+ }
+ if err := validateCodecPayloadSize(len(encoded), c.maxBytes, c.kind, "encode"); err != nil {
+ return nil, err
+ }
+ return append([]byte(nil), encoded...), nil
+}
+
+func (c *jsonCodec[T]) DecodeAndValidate(ctx context.Context, scope ResourceScope, raw []byte) (T, error) {
+ var zero T
+ if ctx == nil {
+ return zero, errors.New("resources: codec decode context is required")
+ }
+
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 {
+ return zero, fmt.Errorf("%w: codec payload is required for kind %q", ErrValidation, c.kind)
+ }
+ if err := validateCodecPayloadSize(len(trimmed), c.maxBytes, c.kind, "decode"); err != nil {
+ return zero, err
+ }
+
+ var spec T
+ if err := json.Unmarshal(trimmed, &spec); err != nil {
+ return zero, fmt.Errorf("resources: decode %q spec: %w", c.kind, err)
+ }
+ if c.validator == nil {
+ return spec, nil
+ }
+ validated, err := c.validator(ctx, scope, spec)
+ if err != nil {
+ return zero, fmt.Errorf("resources: validate %q spec: %w", c.kind, err)
+ }
+ return validated, nil
+}
+
+func (c *jsonCodec[T]) ValidateAndCanonicalizeRaw(
+ ctx context.Context,
+ scope ResourceScope,
+ raw []byte,
+) ([]byte, error) {
+ validated, err := c.DecodeAndValidate(ctx, scope, raw)
+ if err != nil {
+ return nil, err
+ }
+ return c.Encode(validated)
+}
+
+func validateCodecPayloadSize(size int, maxBytes int, kind ResourceKind, operation string) error {
+ if size > maxBytes {
+ return fmt.Errorf(
+ "%w: %s %q payload exceeds %d bytes: %d",
+ ErrPayloadTooLarge,
+ operation,
+ kind,
+ maxBytes,
+ size,
+ )
+ }
+ return nil
+}
+
+type codecRegistration struct {
+ specType reflect.Type
+ codec any
+}
+
+// CodecRegistry holds explicit kind-to-codec registrations for typed adapters.
+type CodecRegistry struct {
+ mu sync.RWMutex
+ codecs map[ResourceKind]codecRegistration
+}
+
+// NewCodecRegistry constructs an empty kind codec registry.
+func NewCodecRegistry() *CodecRegistry {
+ return &CodecRegistry{
+ codecs: make(map[ResourceKind]codecRegistration),
+ }
+}
+
+// ResolveCodec returns the typed codec registered for one resource kind.
+func ResolveCodec[T any](registry *CodecRegistry, kind ResourceKind) (KindCodec[T], error) {
+ if registry == nil {
+ return nil, errors.New("resources: codec registry is required")
+ }
+
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("kind"); err != nil {
+ return nil, err
+ }
+
+ registry.mu.RLock()
+ entry, ok := registry.codecs[normalizedKind]
+ registry.mu.RUnlock()
+ if !ok {
+ return nil, fmt.Errorf("%w: codec not registered for kind %q", ErrCodecNotFound, normalizedKind)
+ }
+
+ codec, ok := entry.codec.(KindCodec[T])
+ if !ok {
+ return nil, fmt.Errorf(
+ "%w: codec for kind %q is %s, not %s",
+ ErrCodecTypeMismatch,
+ normalizedKind,
+ entry.specType,
+ specTypeOf[T](),
+ )
+ }
+ return codec, nil
+}
+
+// RegisterCodec adds one typed codec keyed by its resource kind.
+func RegisterCodec[T any](registry *CodecRegistry, codec KindCodec[T]) error {
+ if registry == nil {
+ return errors.New("resources: codec registry is required")
+ }
+
+ normalizedKind, err := validateCodec(codec)
+ if err != nil {
+ return err
+ }
+
+ registry.mu.Lock()
+ defer registry.mu.Unlock()
+
+ if _, exists := registry.codecs[normalizedKind]; exists {
+ return fmt.Errorf("%w: codec already registered for kind %q", ErrConflict, normalizedKind)
+ }
+
+ registry.codecs[normalizedKind] = codecRegistration{
+ specType: specTypeOf[T](),
+ codec: codec,
+ }
+ return nil
+}
+
+func validateCodec[T any](codec KindCodec[T]) (ResourceKind, error) {
+ if codec == nil {
+ return "", errors.New("resources: codec is required")
+ }
+
+ normalizedKind := codec.Kind().Normalize()
+ if err := normalizedKind.Validate("codec.kind"); err != nil {
+ return "", err
+ }
+ if codec.MaxBytes() <= 0 {
+ return "", fmt.Errorf("%w: codec.max_bytes must be positive: %d", ErrValidation, codec.MaxBytes())
+ }
+ return normalizedKind, nil
+}
+
+func specTypeOf[T any]() reflect.Type {
+ return reflect.TypeFor[T]()
+}
+
+// ValidateAndCanonicalizeIfRegistered validates one raw spec against a registered
+// codec and returns its canonical encoded form. When the kind has no registered
+// codec yet, the original payload is returned unchanged and validated is false.
+func ValidateAndCanonicalizeIfRegistered(
+ ctx context.Context,
+ registry *CodecRegistry,
+ kind ResourceKind,
+ scope ResourceScope,
+ raw []byte,
+) ([]byte, bool, error) {
+ if registry == nil {
+ return append([]byte(nil), raw...), false, nil
+ }
+
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("kind"); err != nil {
+ return nil, false, err
+ }
+
+ registry.mu.RLock()
+ entry, ok := registry.codecs[normalizedKind]
+ registry.mu.RUnlock()
+ if !ok {
+ return append([]byte(nil), raw...), false, nil
+ }
+
+ codec, ok := entry.codec.(rawSpecCodec)
+ if !ok {
+ return nil, true, fmt.Errorf(
+ "%w: codec for kind %q is %s, not a raw validating codec",
+ ErrCodecTypeMismatch,
+ normalizedKind,
+ entry.specType,
+ )
+ }
+
+ canonical, err := codec.ValidateAndCanonicalizeRaw(ctx, scope, raw)
+ if err != nil {
+ return nil, true, err
+ }
+ return canonical, true, nil
+}
diff --git a/internal/resources/doc.go b/internal/resources/doc.go
new file mode 100644
index 000000000..a8700dbbb
--- /dev/null
+++ b/internal/resources/doc.go
@@ -0,0 +1,3 @@
+// Package resources provides the canonical desired-state persistence kernel and
+// typed adapter boundary for extensibility resources.
+package resources
diff --git a/internal/resources/errors.go b/internal/resources/errors.go
new file mode 100644
index 000000000..1d70fcf1f
--- /dev/null
+++ b/internal/resources/errors.go
@@ -0,0 +1,30 @@
+package resources
+
+import "errors"
+
+var (
+ // ErrNotFound reports that no persisted resource matched the lookup.
+ ErrNotFound = errors.New("resources: record not found")
+ // ErrValidation reports that a resource payload or actor failed validation.
+ ErrValidation = errors.New("resources: validation failed")
+ // ErrInvalidScopeBinding reports that a scope and scope identifier are inconsistent.
+ ErrInvalidScopeBinding = errors.New("resources: invalid scope binding")
+ // ErrPermissionDenied reports that the resolved actor lacks authority for the request.
+ ErrPermissionDenied = errors.New("resources: permission denied")
+ // ErrDirectMutationNotAllowed reports that the actor cannot use direct CRUD paths.
+ ErrDirectMutationNotAllowed = errors.New("resources: direct mutation not allowed")
+ // ErrConflict reports optimistic concurrency or ownership conflicts.
+ ErrConflict = errors.New("resources: conflict")
+ // ErrPayloadTooLarge reports that a record or snapshot exceeded configured limits.
+ ErrPayloadTooLarge = errors.New("resources: payload too large")
+ // ErrRateLimited reports that the caller exceeded a configured resource rate limit.
+ ErrRateLimited = errors.New("resources: rate limited")
+ // ErrSessionNotActive reports that the provided session nonce is not the active nonce for the source.
+ ErrSessionNotActive = errors.New("resources: session nonce not active")
+ // ErrStaleSourceVersion reports that the snapshot source version is stale or out of sequence.
+ ErrStaleSourceVersion = errors.New("resources: stale source version")
+ // ErrCodecNotFound reports that no typed codec is registered for a resource kind.
+ ErrCodecNotFound = errors.New("resources: codec not found")
+ // ErrCodecTypeMismatch reports that a registered codec kind was resolved with the wrong spec type.
+ ErrCodecTypeMismatch = errors.New("resources: codec type mismatch")
+)
diff --git a/internal/resources/kernel.go b/internal/resources/kernel.go
new file mode 100644
index 000000000..bcbcae8fc
--- /dev/null
+++ b/internal/resources/kernel.go
@@ -0,0 +1,1356 @@
+package resources
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/pedronauck/agh/internal/store"
+)
+
+const (
+ defaultMaxSpecBytes = 256 << 10
+ defaultMaxSnapshotRecords = 256
+ defaultMaxSnapshotBytes = 2 << 20
+
+ rawRecordSelectQuery = `SELECT
+ kind,
+ id,
+ version,
+ scope_kind,
+ scope_id,
+ owner_kind,
+ owner_id,
+ source_kind,
+ source_id,
+ spec_json,
+ created_at,
+ updated_at
+ FROM resource_records`
+ selectRecordByKeyQuery = rawRecordSelectQuery + `
+ WHERE kind = ? AND id = ?`
+ selectSourceRecordsQuery = rawRecordSelectQuery + `
+ WHERE source_kind = ? AND source_id = ?
+ ORDER BY kind ASC, id ASC`
+ selectSourceStateQuery = `SELECT
+ source_kind,
+ source_id,
+ session_nonce,
+ last_snapshot_version,
+ updated_at
+ FROM resource_source_state
+ WHERE source_kind = ? AND source_id = ?`
+ resourceRecordOrderBy = " ORDER BY kind ASC, id ASC"
+ deleteRecordByVersionQuery = `
+ DELETE FROM resource_records
+ WHERE kind = ? AND id = ? AND version = ?`
+ deleteStaleSourceRecordQuery = `
+ DELETE FROM resource_records
+ WHERE kind = ? AND id = ? AND source_kind = ? AND source_id = ?`
+ deleteSourceRecordsQuery = `
+ DELETE FROM resource_records
+ WHERE source_kind = ? AND source_id = ?`
+ deleteSourceStateQuery = `
+ DELETE FROM resource_source_state
+ WHERE source_kind = ? AND source_id = ?`
+ activateSourceStateQuery = `INSERT INTO resource_source_state (
+ source_kind,
+ source_id,
+ session_nonce,
+ last_snapshot_version,
+ updated_at
+ ) VALUES (?, ?, ?, 0, ?)
+ ON CONFLICT(source_kind, source_id)
+ DO UPDATE SET
+ session_nonce = excluded.session_nonce,
+ last_snapshot_version = 0,
+ updated_at = excluded.updated_at`
+ updateSourceStateQuery = `UPDATE resource_source_state
+ SET last_snapshot_version = ?, updated_at = ?
+ WHERE source_kind = ? AND source_id = ? AND session_nonce = ?`
+)
+
+// Option configures a Kernel instance.
+type Option func(*Kernel)
+
+// WithNow overrides the clock used by the kernel.
+func WithNow(now func() time.Time) Option {
+ return func(k *Kernel) {
+ if now != nil {
+ k.now = now
+ }
+ }
+}
+
+// WithMaxSpecBytes overrides the per-record payload ceiling.
+func WithMaxSpecBytes(limit int) Option {
+ return func(k *Kernel) {
+ if limit > 0 {
+ k.maxSpecBytes = limit
+ }
+ }
+}
+
+// WithMaxSnapshotRecords overrides the per-snapshot record count ceiling.
+func WithMaxSnapshotRecords(limit int) Option {
+ return func(k *Kernel) {
+ if limit > 0 {
+ k.maxSnapshotRecords = limit
+ }
+ }
+}
+
+// WithMaxSnapshotBytes overrides the per-snapshot byte ceiling.
+func WithMaxSnapshotBytes(limit int) Option {
+ return func(k *Kernel) {
+ if limit > 0 {
+ k.maxSnapshotBytes = limit
+ }
+ }
+}
+
+// Kernel implements the canonical raw desired-state persistence contract.
+type Kernel struct {
+ db *sql.DB
+
+ now func() time.Time
+ maxSpecBytes int
+ maxSnapshotRecords int
+ maxSnapshotBytes int
+
+ sourceLocksMu sync.Mutex
+ sourceLocks map[string]*sync.Mutex
+}
+
+var _ RawStore = (*Kernel)(nil)
+var _ SourceSessionManager = (*Kernel)(nil)
+
+// NewKernel constructs a new raw resource persistence kernel over the supplied database.
+func NewKernel(db *sql.DB, opts ...Option) (*Kernel, error) {
+ if db == nil {
+ return nil, errors.New("resources: database is required")
+ }
+
+ kernel := &Kernel{
+ db: db,
+ now: func() time.Time { return time.Now().UTC() },
+ maxSpecBytes: defaultMaxSpecBytes,
+ maxSnapshotRecords: defaultMaxSnapshotRecords,
+ maxSnapshotBytes: defaultMaxSnapshotBytes,
+ sourceLocks: make(map[string]*sync.Mutex),
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(kernel)
+ }
+ }
+ if kernel.now == nil {
+ return nil, errors.New("resources: clock is required")
+ }
+ if kernel.maxSpecBytes <= 0 {
+ return nil, errors.New("resources: max spec bytes must be positive")
+ }
+ if kernel.maxSnapshotRecords <= 0 {
+ return nil, errors.New("resources: max snapshot records must be positive")
+ }
+ if kernel.maxSnapshotBytes <= 0 {
+ return nil, errors.New("resources: max snapshot bytes must be positive")
+ }
+ return kernel, nil
+}
+
+// ActivateSourceSession registers the active nonce and resets the snapshot version counter for one source.
+func (k *Kernel) ActivateSourceSession(
+ ctx context.Context,
+ actor MutationActor,
+ source ResourceSource,
+ sessionNonce string,
+) error {
+ if ctx == nil {
+ return errors.New("resources: activate source session context is required")
+ }
+
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return err
+ }
+ if normalizedActor.Kind == MutationActorKindExtension {
+ return fmt.Errorf("%w: extension actors cannot activate source sessions", ErrPermissionDenied)
+ }
+
+ normalizedSource := source.Normalize()
+ if err := normalizedSource.Validate("source"); err != nil {
+ return err
+ }
+ trimmedNonce := strings.TrimSpace(sessionNonce)
+ if trimmedNonce == "" {
+ return fmt.Errorf("%w: session_nonce is required", ErrValidation)
+ }
+
+ unlock := k.lockSource(normalizedSource)
+ defer unlock()
+
+ return k.withImmediateTransaction(ctx, "activate source session", func(conn *sql.Conn) error {
+ updatedAt := store.FormatTimestamp(k.now())
+ if _, err := conn.ExecContext(
+ ctx,
+ activateSourceStateQuery,
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ trimmedNonce,
+ updatedAt,
+ ); err != nil {
+ return fmt.Errorf(
+ "resources: activate source session %q/%q: %w",
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ err,
+ )
+ }
+ return nil
+ })
+}
+
+// ResetSource deletes all source-owned records and source state in one transaction.
+func (k *Kernel) ResetSource(ctx context.Context, actor MutationActor, source ResourceSource) error {
+ if ctx == nil {
+ return errors.New("resources: reset source context is required")
+ }
+
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return err
+ }
+ if normalizedActor.Kind == MutationActorKindExtension {
+ return fmt.Errorf("%w: extension actors cannot reset sources", ErrPermissionDenied)
+ }
+
+ normalizedSource := source.Normalize()
+ if err := normalizedSource.Validate("source"); err != nil {
+ return err
+ }
+
+ unlock := k.lockSource(normalizedSource)
+ defer unlock()
+
+ return k.withImmediateTransaction(ctx, "reset source", func(conn *sql.Conn) error {
+ if _, err := conn.ExecContext(
+ ctx,
+ deleteSourceRecordsQuery,
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ ); err != nil {
+ return fmt.Errorf(
+ "resources: delete source records %q/%q: %w",
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ err,
+ )
+ }
+ if _, err := conn.ExecContext(
+ ctx,
+ deleteSourceStateQuery,
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ ); err != nil {
+ return fmt.Errorf(
+ "resources: delete source state %q/%q: %w",
+ normalizedSource.Kind,
+ normalizedSource.ID,
+ err,
+ )
+ }
+ return nil
+ })
+}
+
+// PutRaw creates or updates one raw desired-state record using optimistic concurrency.
+func (k *Kernel) PutRaw(ctx context.Context, actor MutationActor, draft RawDraft) (record RawRecord, err error) {
+ if ctx == nil {
+ return RawRecord{}, errors.New("resources: put raw context is required")
+ }
+
+ normalizedActor, normalizedDraft, err := k.preparePutRaw(actor, draft)
+ if err != nil {
+ return RawRecord{}, err
+ }
+
+ tx, err := k.db.BeginTx(ctx, nil)
+ if err != nil {
+ return RawRecord{}, fmt.Errorf("resources: begin put transaction: %w", err)
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ joinCleanupError(&err, rollbackTx(tx))
+ }
+ }()
+
+ record, err = k.putRawWithExecutor(ctx, tx, normalizedActor, normalizedDraft)
+ if err != nil {
+ return RawRecord{}, err
+ }
+ if err = tx.Commit(); err != nil {
+ return RawRecord{}, fmt.Errorf("resources: commit put %q/%q: %w", record.Kind, record.ID, err)
+ }
+ committed = true
+ return record, nil
+}
+
+// DeleteRaw deletes one raw desired-state record using optimistic concurrency.
+func (k *Kernel) DeleteRaw(
+ ctx context.Context,
+ actor MutationActor,
+ kind ResourceKind,
+ id string,
+ expectedVersion int64,
+) (err error) {
+ if ctx == nil {
+ return errors.New("resources: delete raw context is required")
+ }
+
+ normalizedActor, normalizedKind, trimmedID, err := k.prepareDeleteRaw(actor, kind, id)
+ if err != nil {
+ return err
+ }
+
+ tx, err := k.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("resources: begin delete transaction: %w", err)
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ joinCleanupError(&err, rollbackTx(tx))
+ }
+ }()
+
+ err = k.deleteRawWithExecutor(ctx, tx, normalizedActor, normalizedKind, trimmedID, expectedVersion)
+ if err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("resources: commit delete %q/%q: %w", normalizedKind, trimmedID, err)
+ }
+ committed = true
+ return nil
+}
+
+// GetRaw fetches one raw desired-state record under the actor's read boundary.
+func (k *Kernel) GetRaw(ctx context.Context, actor MutationActor, kind ResourceKind, id string) (RawRecord, error) {
+ if ctx == nil {
+ return RawRecord{}, errors.New("resources: get raw context is required")
+ }
+
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return RawRecord{}, err
+ }
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("kind"); err != nil {
+ return RawRecord{}, err
+ }
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" {
+ return RawRecord{}, fmt.Errorf("%w: id is required", ErrValidation)
+ }
+ if !actorAllowsKind(normalizedActor, normalizedKind) {
+ return RawRecord{}, fmt.Errorf("%w: actor cannot read resource kind %q", ErrPermissionDenied, normalizedKind)
+ }
+
+ record, found, err := lookupRecordWithExecutor(ctx, k.db, normalizedKind, trimmedID)
+ if err != nil {
+ return RawRecord{}, err
+ }
+ if !found {
+ return RawRecord{}, ErrNotFound
+ }
+ if err := validateActorReadAccess(normalizedActor, record); err != nil {
+ return RawRecord{}, err
+ }
+ return record, nil
+}
+
+// ListRaw lists raw desired-state records under the actor's read boundary.
+func (k *Kernel) ListRaw(ctx context.Context, actor MutationActor, filter ResourceFilter) ([]RawRecord, error) {
+ if ctx == nil {
+ return nil, errors.New("resources: list raw context is required")
+ }
+
+ normalizedActor, normalizedFilter, err := k.prepareListRaw(actor, filter)
+ if err != nil {
+ return nil, err
+ }
+ if normalizedActor.Kind == MutationActorKindExtension && k.extensionReadGrantsEmpty(normalizedActor) {
+ return []RawRecord{}, nil
+ }
+
+ query, args := buildListRawQuery(normalizedActor, normalizedFilter)
+
+ rows, err := k.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("resources: query records: %w", err)
+ }
+ defer func() {
+ _ = rows.Close()
+ }()
+
+ records := make([]RawRecord, 0)
+ for rows.Next() {
+ record, scanErr := scanRawRecord(rows)
+ if scanErr != nil {
+ return nil, scanErr
+ }
+ if accessErr := validateActorReadAccess(normalizedActor, record); accessErr != nil {
+ if errors.Is(accessErr, ErrPermissionDenied) {
+ continue
+ }
+ return nil, accessErr
+ }
+ records = append(records, record)
+ if normalizedFilter.Limit > 0 && len(records) == normalizedFilter.Limit {
+ break
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("resources: iterate records: %w", err)
+ }
+ return records, nil
+}
+
+// ApplySourceSnapshotRaw replaces one source's desired-state snapshot under optimistic source sequencing.
+func (k *Kernel) ApplySourceSnapshotRaw(ctx context.Context, actor MutationActor, snapshot SourceSnapshot) error {
+ if ctx == nil {
+ return errors.New("resources: apply snapshot context is required")
+ }
+
+ normalizedActor, normalizedSnapshot, normalizedDrafts, err := k.prepareSnapshotApply(actor, snapshot)
+ if err != nil {
+ return err
+ }
+
+ unlock := k.lockSource(normalizedActor.Source)
+ defer unlock()
+
+ return k.withImmediateTransaction(ctx, "apply source snapshot", func(conn *sql.Conn) error {
+ return k.applySnapshotWithExecutor(
+ ctx,
+ conn,
+ normalizedActor,
+ normalizedSnapshot,
+ normalizedDrafts,
+ )
+ })
+}
+
+type sourceState struct {
+ Source ResourceSource
+ SessionNonce string
+ LastSnapshotVersion int64
+ UpdatedAt time.Time
+}
+
+type sqlExecutor interface {
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
+
+func normalizeFilter(filter ResourceFilter) (ResourceFilter, error) {
+ normalized := filter
+ normalized.Kind = filter.Kind.Normalize()
+ if normalized.Kind != "" {
+ if err := normalized.Kind.Validate("filter.kind"); err != nil {
+ return ResourceFilter{}, err
+ }
+ }
+ if filter.Scope != nil {
+ scope := filter.Scope.Normalize()
+ if err := scope.Validate("filter.scope"); err != nil {
+ return ResourceFilter{}, err
+ }
+ normalized.Scope = &scope
+ }
+ if filter.Owner != nil {
+ owner := filter.Owner.Normalize()
+ if err := owner.Validate("filter.owner"); err != nil {
+ return ResourceFilter{}, err
+ }
+ normalized.Owner = &owner
+ }
+ if filter.Source != nil {
+ source := filter.Source.Normalize()
+ if err := source.Validate("filter.source"); err != nil {
+ return ResourceFilter{}, err
+ }
+ normalized.Source = &source
+ }
+ if normalized.Limit < 0 {
+ return ResourceFilter{}, fmt.Errorf("%w: filter.limit cannot be negative: %d", ErrValidation, normalized.Limit)
+ }
+ return normalized, nil
+}
+
+func (k *Kernel) preparePutRaw(actor MutationActor, draft RawDraft) (MutationActor, RawDraft, error) {
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return MutationActor{}, RawDraft{}, err
+ }
+ if normalizedActor.Kind == MutationActorKindExtension {
+ return MutationActor{}, RawDraft{}, fmt.Errorf(
+ "%w: extension actors cannot use direct raw mutations",
+ ErrDirectMutationNotAllowed,
+ )
+ }
+ if err := normalizedActor.Source.Validate("actor.source"); err != nil {
+ return MutationActor{}, RawDraft{}, err
+ }
+
+ normalizedDraft, err := normalizeDraft(draft, k.maxSpecBytes)
+ if err != nil {
+ return MutationActor{}, RawDraft{}, err
+ }
+ if err := validateActorWriteAccess(normalizedActor, normalizedDraft.Kind, normalizedDraft.Scope); err != nil {
+ return MutationActor{}, RawDraft{}, err
+ }
+ return normalizedActor, normalizedDraft, nil
+}
+
+func (k *Kernel) putRawWithExecutor(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ draft RawDraft,
+) (RawRecord, error) {
+ existing, found, err := lookupRecordWithExecutor(ctx, exec, draft.Kind, draft.ID)
+ if err != nil {
+ return RawRecord{}, err
+ }
+ if !found {
+ if draft.ExpectedVersion != 0 {
+ return RawRecord{}, ErrNotFound
+ }
+ return k.insertRawRecord(ctx, exec, actor, draft)
+ }
+
+ if err := validateActorWriteAccess(actor, existing.Kind, existing.Scope); err != nil {
+ return RawRecord{}, err
+ }
+ if existing.Source != actor.Source {
+ return RawRecord{}, fmt.Errorf(
+ "%w: actor cannot mutate source %q/%q",
+ ErrPermissionDenied,
+ existing.Source.Kind,
+ existing.Source.ID,
+ )
+ }
+ if draft.ExpectedVersion == 0 || existing.Version != draft.ExpectedVersion {
+ return RawRecord{}, fmt.Errorf("%w: expected version %d", ErrConflict, draft.ExpectedVersion)
+ }
+
+ now := k.now()
+ record := RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: existing.Version + 1,
+ Scope: draft.Scope,
+ Owner: ownerFromActor(actor),
+ Source: actor.Source,
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: existing.CreatedAt,
+ UpdatedAt: now,
+ }
+ if err := updateRecord(ctx, exec, record, draft.ExpectedVersion); err != nil {
+ return RawRecord{}, err
+ }
+ return record, nil
+}
+
+func (k *Kernel) insertRawRecord(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ draft RawDraft,
+) (RawRecord, error) {
+ now := k.now()
+ record := RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: 1,
+ Scope: draft.Scope,
+ Owner: ownerFromActor(actor),
+ Source: actor.Source,
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := insertRecord(ctx, exec, record); err != nil {
+ return RawRecord{}, err
+ }
+ return record, nil
+}
+
+func (k *Kernel) prepareDeleteRaw(
+ actor MutationActor,
+ kind ResourceKind,
+ id string,
+) (MutationActor, ResourceKind, string, error) {
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return MutationActor{}, "", "", err
+ }
+ if normalizedActor.Kind == MutationActorKindExtension {
+ return MutationActor{}, "", "", fmt.Errorf(
+ "%w: extension actors cannot use direct raw mutations",
+ ErrDirectMutationNotAllowed,
+ )
+ }
+ if err := normalizedActor.Source.Validate("actor.source"); err != nil {
+ return MutationActor{}, "", "", err
+ }
+
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("kind"); err != nil {
+ return MutationActor{}, "", "", err
+ }
+ trimmedID := strings.TrimSpace(id)
+ if trimmedID == "" {
+ return MutationActor{}, "", "", fmt.Errorf("%w: id is required", ErrValidation)
+ }
+ return normalizedActor, normalizedKind, trimmedID, nil
+}
+
+func (k *Kernel) deleteRawWithExecutor(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ kind ResourceKind,
+ id string,
+ expectedVersion int64,
+) error {
+ if expectedVersion < 0 {
+ return fmt.Errorf("%w: expected_version cannot be negative: %d", ErrValidation, expectedVersion)
+ }
+
+ existing, found, err := lookupRecordWithExecutor(ctx, exec, kind, id)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return ErrNotFound
+ }
+ if err := validateActorWriteAccess(actor, existing.Kind, existing.Scope); err != nil {
+ return err
+ }
+ if existing.Source != actor.Source {
+ return fmt.Errorf(
+ "%w: actor cannot mutate source %q/%q",
+ ErrPermissionDenied,
+ existing.Source.Kind,
+ existing.Source.ID,
+ )
+ }
+
+ result, err := exec.ExecContext(ctx, deleteRecordByVersionQuery, kind, id, expectedVersion)
+ if err != nil {
+ return fmt.Errorf("resources: delete record %q/%q: %w", kind, id, err)
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("resources: rows affected for delete %q/%q: %w", kind, id, err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("%w: expected version %d", ErrConflict, expectedVersion)
+ }
+ return nil
+}
+
+func (k *Kernel) prepareListRaw(
+ actor MutationActor,
+ filter ResourceFilter,
+) (MutationActor, ResourceFilter, error) {
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return MutationActor{}, ResourceFilter{}, err
+ }
+ normalizedFilter, err := normalizeFilter(filter)
+ if err != nil {
+ return MutationActor{}, ResourceFilter{}, err
+ }
+
+ if normalizedFilter.Kind != "" && !actorAllowsKind(normalizedActor, normalizedFilter.Kind) {
+ return MutationActor{}, ResourceFilter{}, fmt.Errorf(
+ "%w: actor cannot read resource kind %q",
+ ErrPermissionDenied,
+ normalizedFilter.Kind,
+ )
+ }
+ if normalizedFilter.Scope != nil {
+ if !actorAllowsScopeKind(normalizedActor, normalizedFilter.Scope.Kind) {
+ return MutationActor{}, ResourceFilter{}, fmt.Errorf(
+ "%w: actor cannot read scope kind %q",
+ ErrPermissionDenied,
+ normalizedFilter.Scope.Kind,
+ )
+ }
+ if !actorAllowsScope(normalizedActor, *normalizedFilter.Scope) {
+ return MutationActor{}, ResourceFilter{}, fmt.Errorf(
+ "%w: actor max scope does not allow %q/%q",
+ ErrPermissionDenied,
+ normalizedFilter.Scope.Kind,
+ normalizedFilter.Scope.ID,
+ )
+ }
+ }
+ if normalizedActor.Kind == MutationActorKindExtension &&
+ normalizedFilter.Source != nil &&
+ *normalizedFilter.Source != normalizedActor.Source {
+ return MutationActor{}, ResourceFilter{}, fmt.Errorf(
+ "%w: actor cannot read source %q/%q",
+ ErrPermissionDenied,
+ normalizedFilter.Source.Kind,
+ normalizedFilter.Source.ID,
+ )
+ }
+ return normalizedActor, normalizedFilter, nil
+}
+
+func (k *Kernel) extensionReadGrantsEmpty(actor MutationActor) bool {
+ return actor.Kind == MutationActorKindExtension &&
+ (len(actor.GrantedKinds) == 0 || len(actor.GrantedScopes) == 0)
+}
+
+func buildListRawQuery(actor MutationActor, filter ResourceFilter) (string, []any) {
+ clauses := make([]string, 0, 8)
+ args := make([]any, 0, 10)
+
+ if actor.Kind == MutationActorKindExtension {
+ clauses = append(clauses, "source_kind = ?", "source_id = ?")
+ args = append(args, actor.Source.Kind, actor.Source.ID)
+ }
+ if filter.Kind != "" {
+ clauses = append(clauses, "kind = ?")
+ args = append(args, filter.Kind)
+ }
+ if filter.Scope != nil {
+ clauses = append(clauses, "scope_kind = ?")
+ args = append(args, filter.Scope.Kind)
+ if filter.Scope.Kind == ResourceScopeKindGlobal {
+ clauses = append(clauses, "scope_id IS NULL")
+ } else {
+ clauses = append(clauses, "scope_id = ?")
+ args = append(args, filter.Scope.ID)
+ }
+ }
+ if filter.Owner != nil {
+ clauses = append(clauses, "owner_kind = ?", "owner_id = ?")
+ args = append(args, filter.Owner.Kind, filter.Owner.ID)
+ }
+ if filter.Source != nil && actor.Kind != MutationActorKindExtension {
+ clauses = append(clauses, "source_kind = ?", "source_id = ?")
+ args = append(args, filter.Source.Kind, filter.Source.ID)
+ }
+
+ var builder strings.Builder
+ builder.WriteString(rawRecordSelectQuery)
+ if len(clauses) > 0 {
+ builder.WriteString("\nWHERE ")
+ builder.WriteString(strings.Join(clauses, "\n\tAND "))
+ }
+ builder.WriteString(resourceRecordOrderBy)
+ return builder.String(), args
+}
+
+func (k *Kernel) prepareSnapshotApply(
+ actor MutationActor,
+ snapshot SourceSnapshot,
+) (MutationActor, SourceSnapshot, []RawDraft, error) {
+ normalizedActor, err := normalizeActor(actor)
+ if err != nil {
+ return MutationActor{}, SourceSnapshot{}, nil, err
+ }
+ if normalizedActor.Kind != MutationActorKindExtension {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: only extension actors may apply source snapshots",
+ ErrPermissionDenied,
+ )
+ }
+ if normalizedActor.SessionNonce == "" {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: actor.session_nonce is required",
+ ErrValidation,
+ )
+ }
+ if k.extensionReadGrantsEmpty(normalizedActor) {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: extension actor requires granted kinds and scopes",
+ ErrPermissionDenied,
+ )
+ }
+
+ normalizedSnapshot, err := normalizeSnapshot(snapshot, k.maxSnapshotRecords)
+ if err != nil {
+ return MutationActor{}, SourceSnapshot{}, nil, err
+ }
+
+ normalizedDrafts := make([]RawDraft, 0, len(snapshot.Records))
+ seenKeys := make(map[string]struct{}, len(snapshot.Records))
+ totalBytes := 0
+ for _, draft := range snapshot.Records {
+ normalizedDraft, draftErr := normalizeDraft(draft, k.maxSpecBytes)
+ if draftErr != nil {
+ return MutationActor{}, SourceSnapshot{}, nil, draftErr
+ }
+ if normalizedDraft.ExpectedVersion != 0 {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: snapshot records must use expected_version 0",
+ ErrValidation,
+ )
+ }
+ if accessErr := validateActorWriteAccess(
+ normalizedActor,
+ normalizedDraft.Kind,
+ normalizedDraft.Scope,
+ ); accessErr != nil {
+ return MutationActor{}, SourceSnapshot{}, nil, accessErr
+ }
+
+ key := resourceKey(normalizedDraft.Kind, normalizedDraft.ID)
+ if _, exists := seenKeys[key]; exists {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: duplicate snapshot record %q/%q",
+ ErrValidation,
+ normalizedDraft.Kind,
+ normalizedDraft.ID,
+ )
+ }
+ seenKeys[key] = struct{}{}
+
+ totalBytes += len(normalizedDraft.SpecJSON)
+ if totalBytes > k.maxSnapshotBytes {
+ return MutationActor{}, SourceSnapshot{}, nil, fmt.Errorf(
+ "%w: snapshot payload exceeds %d bytes",
+ ErrPayloadTooLarge,
+ k.maxSnapshotBytes,
+ )
+ }
+ normalizedDrafts = append(normalizedDrafts, normalizedDraft)
+ }
+
+ return normalizedActor, normalizedSnapshot, normalizedDrafts, nil
+}
+
+func (k *Kernel) applySnapshotWithExecutor(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ snapshot SourceSnapshot,
+ drafts []RawDraft,
+) error {
+ state, err := k.requireActiveSourceState(ctx, exec, actor)
+ if err != nil {
+ return err
+ }
+
+ existingRecords, err := listSourceRecordsWithExecutor(ctx, exec, actor.Source)
+ if err != nil {
+ return err
+ }
+ existingByKey := make(map[string]RawRecord, len(existingRecords))
+ for _, record := range existingRecords {
+ existingByKey[resourceKey(record.Kind, record.ID)] = record
+ }
+
+ if err := k.applySnapshotDrafts(ctx, exec, actor, drafts, existingByKey); err != nil {
+ return err
+ }
+ if err := deleteRemovedSourceRecords(ctx, exec, actor.Source, existingRecords, drafts); err != nil {
+ return err
+ }
+ return advanceSourceState(ctx, exec, actor, state, snapshot.SourceVersion, k.now())
+}
+
+func (k *Kernel) requireActiveSourceState(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+) (sourceState, error) {
+ state, found, err := lookupSourceState(ctx, exec, actor.Source)
+ if err != nil {
+ return sourceState{}, err
+ }
+ if !found || state.SessionNonce != actor.SessionNonce {
+ return sourceState{}, fmt.Errorf(
+ "%w: source %q/%q nonce %q",
+ ErrSessionNotActive,
+ actor.Source.Kind,
+ actor.Source.ID,
+ actor.SessionNonce,
+ )
+ }
+ return state, nil
+}
+
+func (k *Kernel) applySnapshotDrafts(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ drafts []RawDraft,
+ existingByKey map[string]RawRecord,
+) error {
+ for _, draft := range drafts {
+ if err := k.applySnapshotDraft(ctx, exec, actor, draft, existingByKey); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (k *Kernel) applySnapshotDraft(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ draft RawDraft,
+ existingByKey map[string]RawRecord,
+) error {
+ key := resourceKey(draft.Kind, draft.ID)
+ existing, found := existingByKey[key]
+ if !found {
+ record, lookupFound, err := lookupRecordWithExecutor(ctx, exec, draft.Kind, draft.ID)
+ if err != nil {
+ return err
+ }
+ if lookupFound {
+ return fmt.Errorf(
+ "%w: snapshot cannot overwrite source %q/%q",
+ ErrConflict,
+ record.Source.Kind,
+ record.Source.ID,
+ )
+ }
+ _, err = k.insertRawRecord(ctx, exec, actor, draft)
+ return err
+ }
+
+ nextRecord := RawRecord{
+ Kind: draft.Kind,
+ ID: draft.ID,
+ Version: existing.Version + 1,
+ Scope: draft.Scope,
+ Owner: ownerFromActor(actor),
+ Source: actor.Source,
+ SpecJSON: append([]byte(nil), draft.SpecJSON...),
+ CreatedAt: existing.CreatedAt,
+ UpdatedAt: k.now(),
+ }
+ if recordsEqual(existing, nextRecord) {
+ return nil
+ }
+ return updateRecord(ctx, exec, nextRecord, existing.Version)
+}
+
+func deleteRemovedSourceRecords(
+ ctx context.Context,
+ exec sqlExecutor,
+ source ResourceSource,
+ existingRecords []RawRecord,
+ drafts []RawDraft,
+) error {
+ desiredKeys := make(map[string]struct{}, len(drafts))
+ for _, draft := range drafts {
+ desiredKeys[resourceKey(draft.Kind, draft.ID)] = struct{}{}
+ }
+ for _, record := range existingRecords {
+ if _, keep := desiredKeys[resourceKey(record.Kind, record.ID)]; keep {
+ continue
+ }
+ if _, err := exec.ExecContext(
+ ctx,
+ deleteStaleSourceRecordQuery,
+ record.Kind,
+ record.ID,
+ source.Kind,
+ source.ID,
+ ); err != nil {
+ return fmt.Errorf(
+ "resources: delete stale source record %q/%q for %q/%q: %w",
+ record.Kind,
+ record.ID,
+ source.Kind,
+ source.ID,
+ err,
+ )
+ }
+ }
+ return nil
+}
+
+func advanceSourceState(
+ ctx context.Context,
+ exec sqlExecutor,
+ actor MutationActor,
+ state sourceState,
+ sourceVersion int64,
+ now time.Time,
+) error {
+ if sourceVersion <= state.LastSnapshotVersion {
+ return fmt.Errorf(
+ "%w: expected > %d, got %d",
+ ErrStaleSourceVersion,
+ state.LastSnapshotVersion,
+ sourceVersion,
+ )
+ }
+
+ result, err := exec.ExecContext(
+ ctx,
+ updateSourceStateQuery,
+ sourceVersion,
+ store.FormatTimestamp(now),
+ actor.Source.Kind,
+ actor.Source.ID,
+ actor.SessionNonce,
+ )
+ if err != nil {
+ return fmt.Errorf(
+ "resources: update source state %q/%q: %w",
+ actor.Source.Kind,
+ actor.Source.ID,
+ err,
+ )
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf(
+ "resources: rows affected for source state %q/%q: %w",
+ actor.Source.Kind,
+ actor.Source.ID,
+ err,
+ )
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf(
+ "%w: source %q/%q nonce %q",
+ ErrSessionNotActive,
+ actor.Source.Kind,
+ actor.Source.ID,
+ actor.SessionNonce,
+ )
+ }
+ return nil
+}
+
+func insertRecord(ctx context.Context, exec sqlExecutor, record RawRecord) error {
+ if _, err := exec.ExecContext(
+ ctx,
+ `INSERT INTO resource_records (
+ kind, id, version, scope_kind, scope_id, owner_kind,
+ owner_id, source_kind, source_id, spec_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ record.Kind,
+ record.ID,
+ record.Version,
+ record.Scope.Kind,
+ nullableScopeID(record.Scope),
+ record.Owner.Kind,
+ record.Owner.ID,
+ record.Source.Kind,
+ record.Source.ID,
+ string(record.SpecJSON),
+ store.FormatTimestamp(record.CreatedAt),
+ store.FormatTimestamp(record.UpdatedAt),
+ ); err != nil {
+ return fmt.Errorf("resources: insert record %q/%q: %w", record.Kind, record.ID, err)
+ }
+ return nil
+}
+
+func updateRecord(ctx context.Context, exec sqlExecutor, record RawRecord, expectedVersion int64) error {
+ result, err := exec.ExecContext(
+ ctx,
+ `UPDATE resource_records
+ SET version = ?, scope_kind = ?, scope_id = ?, owner_kind = ?, owner_id = ?,
+ source_kind = ?, source_id = ?, spec_json = ?, updated_at = ?
+ WHERE kind = ? AND id = ? AND version = ?`,
+ record.Version,
+ record.Scope.Kind,
+ nullableScopeID(record.Scope),
+ record.Owner.Kind,
+ record.Owner.ID,
+ record.Source.Kind,
+ record.Source.ID,
+ string(record.SpecJSON),
+ store.FormatTimestamp(record.UpdatedAt),
+ record.Kind,
+ record.ID,
+ expectedVersion,
+ )
+ if err != nil {
+ return fmt.Errorf("resources: update record %q/%q: %w", record.Kind, record.ID, err)
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("resources: rows affected for update %q/%q: %w", record.Kind, record.ID, err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("%w: expected version %d", ErrConflict, expectedVersion)
+ }
+ return nil
+}
+
+func lookupRecordWithExecutor(
+ ctx context.Context,
+ exec sqlExecutor,
+ kind ResourceKind,
+ id string,
+) (RawRecord, bool, error) {
+ row := exec.QueryRowContext(ctx, selectRecordByKeyQuery, kind, id)
+ record, err := scanRawRecord(row)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ return RawRecord{}, false, nil
+ case err != nil:
+ return RawRecord{}, false, err
+ default:
+ return record, true, nil
+ }
+}
+
+func listSourceRecordsWithExecutor(ctx context.Context, exec sqlExecutor, source ResourceSource) ([]RawRecord, error) {
+ rows, err := exec.QueryContext(
+ ctx,
+ selectSourceRecordsQuery,
+ source.Kind,
+ source.ID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("resources: query source records %q/%q: %w", source.Kind, source.ID, err)
+ }
+ defer func() {
+ _ = rows.Close()
+ }()
+
+ records := make([]RawRecord, 0)
+ for rows.Next() {
+ record, scanErr := scanRawRecord(rows)
+ if scanErr != nil {
+ return nil, scanErr
+ }
+ records = append(records, record)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("resources: iterate source records %q/%q: %w", source.Kind, source.ID, err)
+ }
+ return records, nil
+}
+
+func lookupSourceState(ctx context.Context, exec sqlExecutor, source ResourceSource) (sourceState, bool, error) {
+ var (
+ sourceKind string
+ sourceID string
+ sessionNonce string
+ lastSnapshotVersion int64
+ updatedAtRaw string
+ )
+ if err := exec.QueryRowContext(
+ ctx,
+ selectSourceStateQuery,
+ source.Kind,
+ source.ID,
+ ).Scan(&sourceKind, &sourceID, &sessionNonce, &lastSnapshotVersion, &updatedAtRaw); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return sourceState{}, false, nil
+ }
+ return sourceState{}, false, fmt.Errorf("resources: query source state %q/%q: %w", source.Kind, source.ID, err)
+ }
+ updatedAt, err := store.ParseTimestamp(updatedAtRaw)
+ if err != nil {
+ return sourceState{}, false, fmt.Errorf(
+ "resources: parse source state timestamp %q/%q: %w",
+ source.Kind,
+ source.ID,
+ err,
+ )
+ }
+ return sourceState{
+ Source: ResourceSource{
+ Kind: ResourceSourceKind(sourceKind),
+ ID: sourceID,
+ },
+ SessionNonce: sessionNonce,
+ LastSnapshotVersion: lastSnapshotVersion,
+ UpdatedAt: updatedAt,
+ }, true, nil
+}
+
+type rawRecordScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanRawRecord(scanner rawRecordScanner) (RawRecord, error) {
+ var (
+ record RawRecord
+ scopeKind string
+ scopeID sql.NullString
+ ownerKind string
+ ownerID string
+ sourceKind string
+ sourceID string
+ specJSON string
+ createdAtRaw string
+ updatedAtRaw string
+ )
+ if err := scanner.Scan(
+ &record.Kind,
+ &record.ID,
+ &record.Version,
+ &scopeKind,
+ &scopeID,
+ &ownerKind,
+ &ownerID,
+ &sourceKind,
+ &sourceID,
+ &specJSON,
+ &createdAtRaw,
+ &updatedAtRaw,
+ ); err != nil {
+ return RawRecord{}, err
+ }
+
+ record.Scope = ResourceScope{
+ Kind: ResourceScopeKind(scopeKind),
+ ID: strings.TrimSpace(scopeID.String),
+ }
+ record.Owner = ResourceOwner{
+ Kind: ResourceOwnerKind(ownerKind),
+ ID: ownerID,
+ }
+ record.Source = ResourceSource{
+ Kind: ResourceSourceKind(sourceKind),
+ ID: sourceID,
+ }
+ record.SpecJSON = []byte(specJSON)
+
+ createdAt, err := store.ParseTimestamp(createdAtRaw)
+ if err != nil {
+ return RawRecord{}, fmt.Errorf("resources: parse created_at for %q/%q: %w", record.Kind, record.ID, err)
+ }
+ updatedAt, err := store.ParseTimestamp(updatedAtRaw)
+ if err != nil {
+ return RawRecord{}, fmt.Errorf("resources: parse updated_at for %q/%q: %w", record.Kind, record.ID, err)
+ }
+ record.CreatedAt = createdAt
+ record.UpdatedAt = updatedAt
+ return record, nil
+}
+
+func rollbackTx(tx *sql.Tx) error {
+ if tx == nil {
+ return nil
+ }
+ if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
+ return err
+ }
+ return nil
+}
+
+func rollbackImmediate(ctx context.Context, conn *sql.Conn) error {
+ if conn == nil {
+ return nil
+ }
+ if _, err := conn.ExecContext(ctx, "ROLLBACK"); err != nil {
+ return err
+ }
+ return nil
+}
+
+func joinCleanupError(target *error, cleanupErr error) {
+ if cleanupErr == nil || target == nil {
+ return
+ }
+ if *target == nil {
+ *target = cleanupErr
+ return
+ }
+ *target = errors.Join(*target, cleanupErr)
+}
+
+func (k *Kernel) withImmediateTransaction(
+ ctx context.Context,
+ action string,
+ run func(conn *sql.Conn) error,
+) (err error) {
+ conn, err := k.db.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("resources: open connection for %s: %w", action, err)
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ rollbackCtx := context.WithoutCancel(ctx)
+ if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
+ return fmt.Errorf("resources: begin immediate %s transaction: %w", action, err)
+ }
+
+ finished := false
+ defer func() {
+ if !finished {
+ if rollbackErr := rollbackImmediate(rollbackCtx, conn); rollbackErr != nil && err == nil {
+ err = fmt.Errorf("resources: rollback %s transaction: %w", action, rollbackErr)
+ }
+ }
+ }()
+
+ if err := run(conn); err != nil {
+ return err
+ }
+ if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
+ return fmt.Errorf("resources: commit %s transaction: %w", action, err)
+ }
+
+ finished = true
+ return nil
+}
+
+func (k *Kernel) lockSource(source ResourceSource) func() {
+ key := string(source.Kind) + "\x00" + source.ID
+
+ k.sourceLocksMu.Lock()
+ lock, ok := k.sourceLocks[key]
+ if !ok {
+ lock = &sync.Mutex{}
+ k.sourceLocks[key] = lock
+ }
+ k.sourceLocksMu.Unlock()
+
+ lock.Lock()
+ return func() {
+ lock.Unlock()
+ }
+}
+
+func resourceKey(kind ResourceKind, id string) string {
+ return string(kind) + "\x00" + id
+}
+
+func recordsEqual(left RawRecord, right RawRecord) bool {
+ return left.Kind == right.Kind &&
+ left.ID == right.ID &&
+ left.Scope == right.Scope &&
+ left.Owner == right.Owner &&
+ left.Source == right.Source &&
+ bytes.Equal(left.SpecJSON, right.SpecJSON)
+}
+
+func nullableScopeID(scope ResourceScope) any {
+ if scope.Kind == ResourceScopeKindGlobal {
+ return nil
+ }
+ return scope.ID
+}
diff --git a/internal/resources/kernel_integration_test.go b/internal/resources/kernel_integration_test.go
new file mode 100644
index 000000000..a41906446
--- /dev/null
+++ b/internal/resources/kernel_integration_test.go
@@ -0,0 +1,155 @@
+//go:build integration
+
+package resources
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestKernelSnapshotSequenceConflictAndResetIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, db := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ sourceAlpha := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-alpha"}
+ sourceBravo := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-bravo"}
+
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), sourceAlpha, "nonce-alpha"); err != nil {
+ t.Fatalf("ActivateSourceSession(alpha) error = %v", err)
+ }
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), sourceBravo, "nonce-bravo"); err != nil {
+ t.Fatalf("ActivateSourceSession(bravo) error = %v", err)
+ }
+
+ alphaActor := testExtensionActor("session-alpha", sourceAlpha.ID, "nonce-alpha")
+ bravoActor := testExtensionActor("session-bravo", sourceBravo.ID, "nonce-bravo")
+
+ if _, err := kernel.PutRaw(ctx, testDaemonActor(), RawDraft{
+ Kind: testResourceKind,
+ ID: "daemon-owned",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"daemon-owned"}`),
+ }); err != nil {
+ t.Fatalf("PutRaw(daemon-owned) error = %v", err)
+ }
+
+ if err := kernel.ApplySourceSnapshotRaw(ctx, bravoActor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "foreign-owned",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"foreign-owned"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(bravo/v1) error = %v", err)
+ }
+
+ if err := kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "alpha-v1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-v1"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(alpha/v1) error = %v", err)
+ }
+ if err := kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 2,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "alpha-v2",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-v2"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(alpha/v2) error = %v", err)
+ }
+
+ alphaRecords, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Source: &sourceAlpha})
+ if err != nil {
+ t.Fatalf("ListRaw(alpha source) error = %v", err)
+ }
+ if got, want := len(alphaRecords), 1; got != want {
+ t.Fatalf("len(alpha source records) = %d, want %d", got, want)
+ }
+ if got, want := alphaRecords[0].ID, "alpha-v2"; got != want {
+ t.Fatalf("alpha source record ID = %q, want %q", got, want)
+ }
+
+ err = kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 3,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "daemon-owned",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"conflict-daemon"}`),
+ }},
+ })
+ if !errors.Is(err, ErrConflict) {
+ t.Fatalf("ApplySourceSnapshotRaw(daemon collision) error = %v, want ErrConflict", err)
+ }
+
+ err = kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 3,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "foreign-owned",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"conflict-foreign"}`),
+ }},
+ })
+ if !errors.Is(err, ErrConflict) {
+ t.Fatalf("ApplySourceSnapshotRaw(foreign collision) error = %v, want ErrConflict", err)
+ }
+
+ if err := kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 3,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "alpha-v2",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-v2"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(alpha/v3 retry) error = %v", err)
+ }
+
+ if err := kernel.ResetSource(ctx, testOperatorActor(), sourceAlpha); err != nil {
+ t.Fatalf("ResetSource(alpha) error = %v", err)
+ }
+
+ alphaRecordsAfterReset, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Source: &sourceAlpha})
+ if err != nil {
+ t.Fatalf("ListRaw(alpha source after reset) error = %v", err)
+ }
+ if len(alphaRecordsAfterReset) != 0 {
+ t.Fatalf("alpha records after reset = %#v, want none", alphaRecordsAfterReset)
+ }
+
+ var sourceStateCount int
+ if err := db.QueryRowContext(
+ ctx,
+ `SELECT COUNT(*) FROM resource_source_state WHERE source_kind = ? AND source_id = ?`,
+ sourceAlpha.Kind,
+ sourceAlpha.ID,
+ ).Scan(&sourceStateCount); err != nil {
+ t.Fatalf("QueryRowContext(resource_source_state) error = %v", err)
+ }
+ if got, want := sourceStateCount, 0; got != want {
+ t.Fatalf("resource_source_state rows after reset = %d, want %d", got, want)
+ }
+}
diff --git a/internal/resources/kernel_test.go b/internal/resources/kernel_test.go
new file mode 100644
index 000000000..321c79e90
--- /dev/null
+++ b/internal/resources/kernel_test.go
@@ -0,0 +1,1123 @@
+package resources
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+const testResourceKind = ResourceKind("tool")
+
+func TestKernelPutRawCreateAndStaleVersionConflict(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testDaemonActor()
+
+ record, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-alpha",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw(create) error = %v", err)
+ }
+ if got, want := record.Version, int64(1); got != want {
+ t.Fatalf("record.Version = %d, want %d", got, want)
+ }
+
+ if _, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-alpha",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-v2"}`),
+ }); !errors.Is(err, ErrConflict) {
+ t.Fatalf("PutRaw(stale update) error = %v, want ErrConflict", err)
+ }
+
+ if err := kernel.DeleteRaw(ctx, actor, testResourceKind, "tool-alpha", 0); !errors.Is(err, ErrConflict) {
+ t.Fatalf("DeleteRaw(stale delete) error = %v, want ErrConflict", err)
+ }
+}
+
+func TestKernelPutRawUpdateDeleteAndNotFound(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testDaemonActor()
+
+ record, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-updatable",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw(create) error = %v", err)
+ }
+
+ updated, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-updatable",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"},
+ ExpectedVersion: record.Version,
+ SpecJSON: []byte(`{"name":"beta"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw(update) error = %v", err)
+ }
+ if got, want := updated.Version, int64(2); got != want {
+ t.Fatalf("updated.Version = %d, want %d", got, want)
+ }
+ if got, want := updated.Scope, (ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}); got != want {
+ t.Fatalf("updated.Scope = %#v, want %#v", got, want)
+ }
+
+ if err := kernel.DeleteRaw(ctx, actor, testResourceKind, "tool-updatable", updated.Version); err != nil {
+ t.Fatalf("DeleteRaw() error = %v", err)
+ }
+
+ if _, err := kernel.GetRaw(ctx, actor, testResourceKind, "tool-updatable"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("GetRaw(after delete) error = %v, want ErrNotFound", err)
+ }
+}
+
+func TestKernelPutRawStampsDaemonOwnerOverride(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testDaemonActor()
+ actor.Owner = ResourceOwner{Kind: ResourceOwnerKind("bundle.activation"), ID: "act-owner"}
+
+ record, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "owned-tool",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"owned"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw() error = %v", err)
+ }
+ if got, want := record.Owner, actor.Owner; got != want {
+ t.Fatalf("record.Owner = %#v, want %#v", got, want)
+ }
+}
+
+func TestKernelPutRawRejectsExtensionOwnerOverride(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-owned"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-owned"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+ actor := testExtensionActor("session-owned", source.ID, "nonce-owned")
+ actor.Owner = ResourceOwner{Kind: ResourceOwnerKind("bundle.activation"), ID: "act-owner"}
+
+ err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "extension-owned-tool",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"owned"}`),
+ }},
+ })
+ if !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("ApplySourceSnapshotRaw() error = %v, want ErrPermissionDenied", err)
+ }
+}
+
+func TestKernelPutRawRejectsInvalidScopeBinding(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testDaemonActor()
+
+ testCases := []struct {
+ name string
+ scope ResourceScope
+ wantErr error
+ }{
+ {
+ name: "omitted scope",
+ scope: ResourceScope{},
+ wantErr: ErrValidation,
+ },
+ {
+ name: "global with scope id",
+ scope: ResourceScope{Kind: ResourceScopeKindGlobal, ID: "ws-1"},
+ wantErr: ErrInvalidScopeBinding,
+ },
+ {
+ name: "workspace without scope id",
+ scope: ResourceScope{Kind: ResourceScopeKindWorkspace},
+ wantErr: ErrInvalidScopeBinding,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-invalid-" + tc.name,
+ Scope: tc.scope,
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"invalid"}`),
+ })
+ if !errors.Is(err, tc.wantErr) {
+ t.Fatalf("PutRaw(%s) error = %v, want %v", tc.name, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestKernelApplySourceSnapshotRejectsInvalidNonceVersionAndPayloadLimits(t *testing.T) {
+ t.Parallel()
+
+ t.Run("non-active nonce", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-alpha"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-active"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ err := kernel.ApplySourceSnapshotRaw(
+ ctx,
+ testExtensionActor("session-1", source.ID, "nonce-stale"),
+ SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-alpha",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ }},
+ },
+ )
+ if !errors.Is(err, ErrSessionNotActive) {
+ t.Fatalf("ApplySourceSnapshotRaw(non-active nonce) error = %v, want ErrSessionNotActive", err)
+ }
+ })
+
+ t.Run("stale source version", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-bravo"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-1"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-2", source.ID, "nonce-1")
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-bravo",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"bravo"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(version 1) error = %v", err)
+ }
+
+ err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-bravo",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"bravo-v2"}`),
+ }},
+ })
+ if !errors.Is(err, ErrStaleSourceVersion) {
+ t.Fatalf("ApplySourceSnapshotRaw(stale version) error = %v, want ErrStaleSourceVersion", err)
+ }
+ })
+
+ t.Run("per-record payload limit", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t, WithMaxSpecBytes(8))
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-charlie"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-2"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ err := kernel.ApplySourceSnapshotRaw(ctx, testExtensionActor("session-3", source.ID, "nonce-2"), SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-charlie",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"too-large"}`),
+ }},
+ })
+ if !errors.Is(err, ErrPayloadTooLarge) {
+ t.Fatalf("ApplySourceSnapshotRaw(per-record limit) error = %v, want ErrPayloadTooLarge", err)
+ }
+ })
+
+ t.Run("per-call payload limit", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t, WithMaxSpecBytes(64), WithMaxSnapshotBytes(18))
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-delta"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-3"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ err := kernel.ApplySourceSnapshotRaw(ctx, testExtensionActor("session-4", source.ID, "nonce-3"), SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{
+ {
+ Kind: testResourceKind,
+ ID: "tool-delta-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"n":"12345"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "tool-delta-2",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"n":"67890"}`),
+ },
+ },
+ })
+ if !errors.Is(err, ErrPayloadTooLarge) {
+ t.Fatalf("ApplySourceSnapshotRaw(per-call limit) error = %v, want ErrPayloadTooLarge", err)
+ }
+ })
+}
+
+func TestKernelStampsOwnerAndSourceFromActor(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testOperatorActor()
+
+ record, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-owned",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"owner_kind":"fake","owner_id":"fake","source_kind":"fake","source_id":"fake"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw() error = %v", err)
+ }
+
+ if got, want := record.Owner, (ResourceOwner{Kind: ResourceOwnerKind(actor.Kind), ID: actor.ID}); got != want {
+ t.Fatalf("record.Owner = %#v, want %#v", got, want)
+ }
+ if got, want := record.Source, actor.Source; got != want {
+ t.Fatalf("record.Source = %#v, want %#v", got, want)
+ }
+}
+
+func TestKernelActivateSessionNoOpSnapshotAndReset(t *testing.T) {
+ t.Parallel()
+
+ kernel, db := openTestKernel(t)
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-noop"}
+
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-noop"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-noop", source.ID, "nonce-noop")
+ firstSnapshot := SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-noop",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"same"}`),
+ }},
+ }
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, firstSnapshot); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(v1) error = %v", err)
+ }
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 2,
+ Records: firstSnapshot.Records,
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(v2 no-op) error = %v", err)
+ }
+
+ record, err := kernel.GetRaw(ctx, actor, testResourceKind, "tool-noop")
+ if err != nil {
+ t.Fatalf("GetRaw() error = %v", err)
+ }
+ if got, want := record.Version, int64(1); got != want {
+ t.Fatalf("record.Version after no-op snapshot = %d, want %d", got, want)
+ }
+
+ var lastSnapshotVersion int64
+ if err := db.QueryRowContext(
+ ctx,
+ `SELECT last_snapshot_version FROM resource_source_state WHERE source_kind = ? AND source_id = ?`,
+ source.Kind,
+ source.ID,
+ ).Scan(&lastSnapshotVersion); err != nil {
+ t.Fatalf("QueryRowContext(resource_source_state) error = %v", err)
+ }
+ if got, want := lastSnapshotVersion, int64(2); got != want {
+ t.Fatalf("last_snapshot_version = %d, want %d", got, want)
+ }
+
+ if err := kernel.ResetSource(ctx, testOperatorActor(), source); err != nil {
+ t.Fatalf("ResetSource() error = %v", err)
+ }
+
+ records, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Source: &source})
+ if err != nil {
+ t.Fatalf("ListRaw(after reset) error = %v", err)
+ }
+ if len(records) != 0 {
+ t.Fatalf("records after reset = %#v, want none", records)
+ }
+}
+
+func TestKernelGetAndListEnforceSourceAndScope(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ sourceAlpha := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-alpha"}
+ sourceBravo := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-bravo"}
+
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), sourceAlpha, "nonce-alpha"); err != nil {
+ t.Fatalf("ActivateSourceSession(alpha) error = %v", err)
+ }
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), sourceBravo, "nonce-bravo"); err != nil {
+ t.Fatalf("ActivateSourceSession(bravo) error = %v", err)
+ }
+
+ alphaActor := testExtensionActor("session-alpha", sourceAlpha.ID, "nonce-alpha")
+ bravoActor := testExtensionActor("session-bravo", sourceBravo.ID, "nonce-bravo")
+
+ if err := kernel.ApplySourceSnapshotRaw(ctx, alphaActor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{
+ {
+ Kind: testResourceKind,
+ ID: "alpha-global",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-global"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "alpha-workspace",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha-workspace"}`),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(alpha) error = %v", err)
+ }
+ if err := kernel.ApplySourceSnapshotRaw(ctx, bravoActor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "bravo-global",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"bravo-global"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(bravo) error = %v", err)
+ }
+
+ records, err := kernel.ListRaw(ctx, alphaActor, ResourceFilter{})
+ if err != nil {
+ t.Fatalf("ListRaw(alpha) error = %v", err)
+ }
+ if got, want := len(records), 2; got != want {
+ t.Fatalf("len(ListRaw(alpha)) = %d, want %d", got, want)
+ }
+
+ if _, err := kernel.GetRaw(
+ ctx,
+ alphaActor,
+ testResourceKind,
+ "bravo-global",
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("GetRaw(foreign source) error = %v, want ErrPermissionDenied", err)
+ }
+
+ workspaceActor := alphaActor
+ workspaceActor.MaxScope = ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}
+ workspaceActor.GrantedScopes = []ResourceScopeKind{ResourceScopeKindWorkspace}
+
+ workspaceRecords, err := kernel.ListRaw(ctx, workspaceActor, ResourceFilter{})
+ if err != nil {
+ t.Fatalf("ListRaw(workspace) error = %v", err)
+ }
+ if got, want := len(workspaceRecords), 1; got != want {
+ t.Fatalf("len(ListRaw(workspace)) = %d, want %d", got, want)
+ }
+ if got, want := workspaceRecords[0].ID, "alpha-workspace"; got != want {
+ t.Fatalf("workspace record ID = %q, want %q", got, want)
+ }
+}
+
+func TestKernelListRawFilterValidationAndSourceOwnerFilters(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ actor := testOperatorActor()
+
+ if _, err := kernel.PutRaw(ctx, actor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-filtered",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"filtered"}`),
+ }); err != nil {
+ t.Fatalf("PutRaw() error = %v", err)
+ }
+
+ if _, err := kernel.ListRaw(ctx, actor, ResourceFilter{Limit: -1}); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ListRaw(negative limit) error = %v, want ErrValidation", err)
+ }
+
+ owner := ResourceOwner{Kind: ResourceOwnerKind(" operator "), ID: " operator-1 "}
+ source := ResourceSource{Kind: ResourceSourceKind(" daemon "), ID: " operator-control "}
+ records, err := kernel.ListRaw(ctx, actor, ResourceFilter{
+ Owner: &owner,
+ Source: &source,
+ })
+ if err != nil {
+ t.Fatalf("ListRaw(owner+source filter) error = %v", err)
+ }
+ if got, want := len(records), 1; got != want {
+ t.Fatalf("len(ListRaw(owner+source filter)) = %d, want %d", got, want)
+ }
+
+ emptyGrantActor := testExtensionActor("session-empty", "ext-empty", "nonce-empty")
+ emptyGrantActor.GrantedKinds = nil
+ emptyGrantActor.GrantedScopes = nil
+ records, err = kernel.ListRaw(ctx, emptyGrantActor, ResourceFilter{})
+ if err != nil {
+ t.Fatalf("ListRaw(empty grants) error = %v", err)
+ }
+ if len(records) != 0 {
+ t.Fatalf("ListRaw(empty grants) = %#v, want none", records)
+ }
+}
+
+func TestKernelApplySourceSnapshotRejectsRecordCountAndEmptyGrants(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t, WithMaxSnapshotRecords(1))
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-limits"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-limits"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-limits", source.ID, "nonce-limits")
+ err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{
+ {
+ Kind: testResourceKind,
+ ID: "tool-limit-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"one"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "tool-limit-2",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"two"}`),
+ },
+ },
+ })
+ if !errors.Is(err, ErrPayloadTooLarge) {
+ t.Fatalf("ApplySourceSnapshotRaw(too many records) error = %v, want ErrPayloadTooLarge", err)
+ }
+
+ noGrantActor := actor
+ noGrantActor.GrantedKinds = nil
+ noGrantActor.GrantedScopes = nil
+ err = kernel.ApplySourceSnapshotRaw(ctx, noGrantActor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: nil,
+ })
+ if !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("ApplySourceSnapshotRaw(empty grants) error = %v, want ErrPermissionDenied", err)
+ }
+}
+
+func TestKernelApplySourceSnapshotUpdatesAndDeletesExistingRecords(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-update-delete"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-update-delete"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-update-delete", source.ID, "nonce-update-delete")
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{
+ {
+ Kind: testResourceKind,
+ ID: "tool-keep",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"old"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "tool-drop",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"drop"}`),
+ },
+ },
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(v1) error = %v", err)
+ }
+
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 2,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-keep",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"new"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(v2) error = %v", err)
+ }
+
+ updated, err := kernel.GetRaw(ctx, actor, testResourceKind, "tool-keep")
+ if err != nil {
+ t.Fatalf("GetRaw(updated) error = %v", err)
+ }
+ if got, want := updated.Version, int64(2); got != want {
+ t.Fatalf("updated.Version = %d, want %d", got, want)
+ }
+ if got, want := updated.Scope, (ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}); got != want {
+ t.Fatalf("updated.Scope = %#v, want %#v", got, want)
+ }
+
+ if _, err := kernel.GetRaw(ctx, actor, testResourceKind, "tool-drop"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("GetRaw(deleted) error = %v, want ErrNotFound", err)
+ }
+}
+
+func TestNewKernelAndAuxiliaryTypesValidation(t *testing.T) {
+ t.Parallel()
+
+ if _, err := NewKernel(nil); err == nil {
+ t.Fatal("NewKernel(nil) error = nil, want non-nil")
+ }
+
+ normalizedSource := (ResourceSource{
+ Kind: ResourceSourceKind(" extension "),
+ ID: " ext-alpha ",
+ }).Normalize()
+ if got, want := normalizedSource, (ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-alpha"}); got != want {
+ t.Fatalf("ResourceSource.Normalize() = %#v, want %#v", got, want)
+ }
+ if err := (ResourceSource{}).Validate("source"); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ResourceSource.Validate() error = %v, want ErrValidation", err)
+ }
+
+ normalizedOwner := (ResourceOwner{
+ Kind: ResourceOwnerKind(" daemon "),
+ ID: " control ",
+ }).Normalize()
+ if got, want := normalizedOwner, (ResourceOwner{Kind: ResourceOwnerKind("daemon"), ID: "control"}); got != want {
+ t.Fatalf("ResourceOwner.Normalize() = %#v, want %#v", got, want)
+ }
+ if err := (ResourceOwner{}).Validate("owner"); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ResourceOwner.Validate() error = %v, want ErrValidation", err)
+ }
+}
+
+func TestKernelValidationAndAuthorityEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ t.Run("activate source session rejects extension actor and blank nonce", func(t *testing.T) {
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-edge"}
+ if err := kernel.ActivateSourceSession(
+ ctx,
+ testExtensionActor("session-edge", source.ID, "nonce"),
+ source,
+ "nonce",
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("ActivateSourceSession(extension actor) error = %v, want ErrPermissionDenied", err)
+ }
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, " "); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ActivateSourceSession(blank nonce) error = %v, want ErrValidation", err)
+ }
+ })
+
+ t.Run("reset source rejects extension actor", func(t *testing.T) {
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-reset"}
+ if err := kernel.ResetSource(
+ ctx,
+ testExtensionActor("session-reset", source.ID, "nonce"),
+ source,
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("ResetSource(extension actor) error = %v, want ErrPermissionDenied", err)
+ }
+ })
+
+ t.Run("put rejects scope escalation and source mismatch", func(t *testing.T) {
+ workspaceActor := testOperatorActor()
+ workspaceActor.MaxScope = ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}
+ workspaceActor.GrantedScopes = []ResourceScopeKind{ResourceScopeKindWorkspace}
+ workspaceActor.GrantedKinds = []ResourceKind{testResourceKind}
+
+ if _, err := kernel.PutRaw(ctx, workspaceActor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-scope-denied",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"denied"}`),
+ }); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("PutRaw(scope escalation) error = %v, want ErrPermissionDenied", err)
+ }
+
+ creator := testDaemonActor()
+ record, err := kernel.PutRaw(ctx, creator, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-source-mismatch",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ })
+ if err != nil {
+ t.Fatalf("PutRaw(create source mismatch seed) error = %v", err)
+ }
+
+ otherSourceActor := testDaemonActor()
+ otherSourceActor.Source = ResourceSource{Kind: ResourceSourceKind("daemon"), ID: "other-system"}
+ if _, err := kernel.PutRaw(ctx, otherSourceActor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-source-mismatch",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: record.Version,
+ SpecJSON: []byte(`{"name":"beta"}`),
+ }); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("PutRaw(source mismatch) error = %v, want ErrPermissionDenied", err)
+ }
+ })
+
+ t.Run("list and get reject invalid source and scope access", func(t *testing.T) {
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-read"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-read"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-read", source.ID, "nonce-read")
+ if err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "tool-read",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"read"}`),
+ }},
+ }); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw(read seed) error = %v", err)
+ }
+
+ foreignSource := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-foreign"}
+ if _, err := kernel.ListRaw(
+ ctx,
+ actor,
+ ResourceFilter{Source: &foreignSource},
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("ListRaw(foreign source filter) error = %v, want ErrPermissionDenied", err)
+ }
+
+ workspaceActor := actor
+ workspaceActor.MaxScope = ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}
+ workspaceActor.GrantedScopes = []ResourceScopeKind{ResourceScopeKindWorkspace}
+ if _, err := kernel.GetRaw(
+ ctx,
+ workspaceActor,
+ testResourceKind,
+ "tool-read",
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("GetRaw(workspace actor reading global record) error = %v, want ErrPermissionDenied", err)
+ }
+ })
+
+ t.Run("snapshot rejects non-extension actor duplicate keys and blank nonce", func(t *testing.T) {
+ source := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-snapshot-edge"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), source, "nonce-snapshot"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+
+ if err := kernel.ApplySourceSnapshotRaw(
+ ctx,
+ testDaemonActor(),
+ SourceSnapshot{SourceVersion: 1},
+ ); !errors.Is(
+ err,
+ ErrPermissionDenied,
+ ) {
+ t.Fatalf("ApplySourceSnapshotRaw(non-extension actor) error = %v, want ErrPermissionDenied", err)
+ }
+
+ blankNonceActor := testExtensionActor("session-blank", source.ID, "")
+ if err := kernel.ApplySourceSnapshotRaw(
+ ctx,
+ blankNonceActor,
+ SourceSnapshot{SourceVersion: 1},
+ ); !errors.Is(
+ err,
+ ErrValidation,
+ ) {
+ t.Fatalf("ApplySourceSnapshotRaw(blank nonce) error = %v, want ErrValidation", err)
+ }
+
+ actor := testExtensionActor("session-snapshot", source.ID, "nonce-snapshot")
+ err := kernel.ApplySourceSnapshotRaw(ctx, actor, SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{
+ {
+ Kind: testResourceKind,
+ ID: "tool-dup",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"dup-1"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "tool-dup",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"dup-2"}`),
+ },
+ },
+ })
+ if !errors.Is(err, ErrValidation) {
+ t.Fatalf("ApplySourceSnapshotRaw(duplicate keys) error = %v, want ErrValidation", err)
+ }
+ })
+}
+
+func TestKernelHelperPathsAndMissingState(t *testing.T) {
+ t.Parallel()
+
+ kernel, db := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ if err := kernel.DeleteRaw(
+ ctx,
+ testDaemonActor(),
+ testResourceKind,
+ "missing-record",
+ 1,
+ ); !errors.Is(
+ err,
+ ErrNotFound,
+ ) {
+ t.Fatalf("DeleteRaw(missing) error = %v, want ErrNotFound", err)
+ }
+
+ if _, err := normalizeFilter(ResourceFilter{
+ Scope: &ResourceScope{Kind: ResourceScopeKindGlobal, ID: "ws-1"},
+ }); !errors.Is(err, ErrInvalidScopeBinding) {
+ t.Fatalf("normalizeFilter(invalid scope) error = %v, want ErrInvalidScopeBinding", err)
+ }
+
+ if _, err := normalizeFilter(ResourceFilter{
+ Source: &ResourceSource{},
+ }); !errors.Is(err, ErrValidation) {
+ t.Fatalf("normalizeFilter(invalid source) error = %v, want ErrValidation", err)
+ }
+
+ state, found, err := lookupSourceState(ctx, db, ResourceSource{
+ Kind: ResourceSourceKind("extension"),
+ ID: "missing-source",
+ })
+ if err != nil {
+ t.Fatalf("lookupSourceState(missing) error = %v", err)
+ }
+ if found {
+ t.Fatalf("lookupSourceState(missing) found = true, state = %#v, want false", state)
+ }
+
+ if err := rollbackTx(nil); err != nil {
+ t.Fatalf("rollbackTx(nil) error = %v", err)
+ }
+ if err := rollbackImmediate(ctx, nil); err != nil {
+ t.Fatalf("rollbackImmediate(nil) error = %v", err)
+ }
+}
+
+func TestKernelAdditionalValidationBranches(t *testing.T) {
+ t.Parallel()
+
+ t.Run("constructor rejects invalid option state", func(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ opt Option
+ }{
+ {
+ name: "nil clock",
+ opt: func(k *Kernel) {
+ k.now = nil
+ },
+ },
+ {
+ name: "zero max spec",
+ opt: func(k *Kernel) {
+ k.maxSpecBytes = 0
+ },
+ },
+ {
+ name: "zero max snapshot records",
+ opt: func(k *Kernel) {
+ k.maxSnapshotRecords = 0
+ },
+ },
+ {
+ name: "zero max snapshot bytes",
+ opt: func(k *Kernel) {
+ k.maxSnapshotBytes = 0
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ if _, err := NewKernel(&sql.DB{}, tc.opt); err == nil {
+ t.Fatalf("NewKernel(%s) error = nil, want non-nil", tc.name)
+ }
+ })
+ }
+ })
+
+ t.Run("direct mutation validation", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ invalidSourceActor := testDaemonActor()
+ invalidSourceActor.Source = ResourceSource{}
+ if _, err := kernel.PutRaw(ctx, invalidSourceActor, RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-invalid-source",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"invalid-source"}`),
+ }); !errors.Is(err, ErrValidation) {
+ t.Fatalf("PutRaw(invalid actor source) error = %v, want ErrValidation", err)
+ }
+
+ if _, err := kernel.PutRaw(ctx, testDaemonActor(), RawDraft{
+ Kind: testResourceKind,
+ ID: "tool-missing-update",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 1,
+ SpecJSON: []byte(`{"name":"missing"}`),
+ }); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("PutRaw(missing update) error = %v, want ErrNotFound", err)
+ }
+
+ if err := kernel.DeleteRaw(
+ ctx,
+ testDaemonActor(),
+ testResourceKind,
+ "tool-negative",
+ -1,
+ ); !errors.Is(
+ err,
+ ErrValidation,
+ ) {
+ t.Fatalf("DeleteRaw(negative version) error = %v, want ErrValidation", err)
+ }
+
+ if err := kernel.DeleteRaw(
+ ctx,
+ testExtensionActor("session-direct-delete", "ext-delete", "nonce-delete"),
+ testResourceKind,
+ "tool-direct-delete",
+ 1,
+ ); !errors.Is(err, ErrDirectMutationNotAllowed) {
+ t.Fatalf("DeleteRaw(extension actor) error = %v, want ErrDirectMutationNotAllowed", err)
+ }
+ })
+
+ t.Run("read and source management validation", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ if _, err := kernel.GetRaw(ctx, testDaemonActor(), testResourceKind, " "); !errors.Is(err, ErrValidation) {
+ t.Fatalf("GetRaw(blank id) error = %v, want ErrValidation", err)
+ }
+
+ limitedActor := testExtensionActor("session-read-filter", "ext-read-filter", "nonce-read-filter")
+ limitedActor.GrantedKinds = []ResourceKind{ResourceKind("other")}
+ if _, err := kernel.ListRaw(ctx, limitedActor, ResourceFilter{
+ Kind: testResourceKind,
+ }); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("ListRaw(denied kind filter) error = %v, want ErrPermissionDenied", err)
+ }
+
+ if err := kernel.ResetSource(ctx, testDaemonActor(), ResourceSource{}); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ResetSource(invalid source) error = %v, want ErrValidation", err)
+ }
+ })
+
+ t.Run("join cleanup error combines errors", func(t *testing.T) {
+ t.Parallel()
+
+ cleanupErr := errors.New("cleanup")
+
+ var err error
+ joinCleanupError(&err, cleanupErr)
+ if !errors.Is(err, cleanupErr) {
+ t.Fatalf("joinCleanupError(nil target) error = %v, want cleanup error", err)
+ }
+
+ primaryErr := errors.New("primary")
+ err = primaryErr
+ joinCleanupError(&err, cleanupErr)
+ if !errors.Is(err, primaryErr) || !errors.Is(err, cleanupErr) {
+ t.Fatalf("joinCleanupError(joined) error = %v, want both primary and cleanup errors", err)
+ }
+ })
+}
+
+func openTestKernel(t *testing.T, opts ...Option) (*Kernel, *sql.DB) {
+ t.Helper()
+
+ db, err := store.OpenSQLiteDatabase(
+ testutil.Context(t),
+ filepath.Join(t.TempDir(), store.GlobalDatabaseName),
+ func(ctx context.Context, db *sql.DB) error {
+ return store.EnsureSchema(ctx, db, SchemaStatements())
+ },
+ )
+ if err != nil {
+ t.Fatalf("OpenSQLiteDatabase() error = %v", err)
+ }
+ t.Cleanup(func() {
+ if closeErr := db.Close(); closeErr != nil {
+ t.Fatalf("db.Close() error = %v", closeErr)
+ }
+ })
+
+ options := append([]Option{
+ WithNow(func() time.Time {
+ return time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
+ }),
+ }, opts...)
+ kernel, err := NewKernel(db, options...)
+ if err != nil {
+ t.Fatalf("NewKernel() error = %v", err)
+ }
+ return kernel, db
+}
+
+func testDaemonActor() MutationActor {
+ return MutationActor{
+ Kind: MutationActorKindDaemon,
+ ID: "daemon-control",
+ Source: ResourceSource{Kind: ResourceSourceKind("daemon"), ID: "system"},
+ MaxScope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ }
+}
+
+func testOperatorActor() MutationActor {
+ return MutationActor{
+ Kind: MutationActorKindOperator,
+ ID: "operator-1",
+ Source: ResourceSource{Kind: ResourceSourceKind("daemon"), ID: "operator-control"},
+ MaxScope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ }
+}
+
+func testExtensionActor(sessionID string, sourceID string, nonce string) MutationActor {
+ return MutationActor{
+ Kind: MutationActorKindExtension,
+ ID: sessionID,
+ SessionNonce: nonce,
+ Source: ResourceSource{Kind: ResourceSourceKind("extension"), ID: sourceID},
+ MaxScope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ GrantedKinds: []ResourceKind{testResourceKind},
+ GrantedScopes: []ResourceScopeKind{ResourceScopeKindGlobal, ResourceScopeKindWorkspace},
+ }
+}
diff --git a/internal/resources/projector.go b/internal/resources/projector.go
new file mode 100644
index 000000000..eb0480997
--- /dev/null
+++ b/internal/resources/projector.go
@@ -0,0 +1,207 @@
+package resources
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+const (
+ bundleKind = ResourceKind("bundle")
+ bundleActivationKind = ResourceKind("bundle.activation")
+)
+
+// ProjectionPlan is the generic metadata surface returned by domain projectors.
+type ProjectionPlan interface {
+ Kind() ResourceKind
+ Revision() int64
+ OperationCount() int
+}
+
+// TypedProjector is the standard single-kind typed projector contract.
+type TypedProjector[T any] interface {
+ Kind() ResourceKind
+ DependsOn() []ResourceKind
+ Build(ctx context.Context, records []Record[T]) (ProjectionPlan, error)
+ Apply(ctx context.Context, plan ProjectionPlan) error
+}
+
+// BundleActivationProjector is the explicit mixed-kind projector escape hatch for bundle activations.
+type BundleActivationProjector[A any, B any] interface {
+ Build(ctx context.Context, activations []Record[A], bundles []Record[B]) (ProjectionPlan, error)
+ Apply(ctx context.Context, plan ProjectionPlan) error
+}
+
+// ProjectorRegistration is an opaque registration token consumed by internal projector wiring.
+type ProjectorRegistration interface {
+ Kind() ResourceKind
+ DependsOn() []ResourceKind
+ projectorRegistration()
+}
+
+type projectionInput struct {
+ kind ResourceKind
+ revision int64
+ records []RawRecord
+ dependencies map[ResourceKind][]RawRecord
+}
+
+type projector interface {
+ Kind() ResourceKind
+ DependsOn() []ResourceKind
+ Build(ctx context.Context, input projectionInput) (ProjectionPlan, error)
+ Apply(ctx context.Context, plan ProjectionPlan) error
+}
+
+type projectorRegistration struct {
+ kind ResourceKind
+ dependsOn []ResourceKind
+ build func(ctx context.Context, input projectionInput) (ProjectionPlan, error)
+ apply func(ctx context.Context, plan ProjectionPlan) error
+}
+
+func (r *projectorRegistration) Kind() ResourceKind {
+ return r.kind
+}
+
+func (r *projectorRegistration) DependsOn() []ResourceKind {
+ return append([]ResourceKind(nil), r.dependsOn...)
+}
+
+func (r *projectorRegistration) projectorRegistration() {}
+
+func (r *projectorRegistration) Build(ctx context.Context, input projectionInput) (ProjectionPlan, error) {
+ return r.build(ctx, input)
+}
+
+func (r *projectorRegistration) Apply(ctx context.Context, plan ProjectionPlan) error {
+ return r.apply(ctx, plan)
+}
+
+// NewTypedProjectorRegistration adapts a single-kind typed projector to the internal raw reconcile seam.
+func NewTypedProjectorRegistration[T any](
+ codec KindCodec[T],
+ projector TypedProjector[T],
+) (ProjectorRegistration, error) {
+ normalizedKind, err := validateCodec(codec)
+ if err != nil {
+ return nil, err
+ }
+ if projector == nil {
+ return nil, errors.New("resources: typed projector is required")
+ }
+ if projector.Kind().Normalize() != normalizedKind {
+ return nil, fmt.Errorf(
+ "%w: typed projector kind %q does not match codec kind %q",
+ ErrValidation,
+ projector.Kind(),
+ normalizedKind,
+ )
+ }
+
+ dependsOn := normalizeKinds(projector.DependsOn())
+ return &projectorRegistration{
+ kind: normalizedKind,
+ dependsOn: dependsOn,
+ build: func(ctx context.Context, input projectionInput) (ProjectionPlan, error) {
+ if input.kind.Normalize() != normalizedKind {
+ return nil, fmt.Errorf(
+ "%w: typed projector build expected kind %q, got %q",
+ ErrValidation,
+ normalizedKind,
+ input.kind,
+ )
+ }
+ if input.revision < 0 {
+ return nil, fmt.Errorf("%w: revision cannot be negative: %d", ErrValidation, input.revision)
+ }
+
+ records, err := decodeTypedRecords(ctx, codec, input.records)
+ if err != nil {
+ return nil, err
+ }
+ return projector.Build(ctx, records)
+ },
+ apply: projector.Apply,
+ }, nil
+}
+
+// NewBundleActivationProjectorRegistration adapts the explicit mixed-kind bundle activation projector seam.
+func NewBundleActivationProjectorRegistration[A any, B any](
+ registry *CodecRegistry,
+ projector BundleActivationProjector[A, B],
+) (ProjectorRegistration, error) {
+ if projector == nil {
+ return nil, errors.New("resources: bundle activation projector is required")
+ }
+
+ activationCodec, err := ResolveCodec[A](registry, bundleActivationKind)
+ if err != nil {
+ return nil, err
+ }
+ bundleCodec, err := ResolveCodec[B](registry, bundleKind)
+ if err != nil {
+ return nil, err
+ }
+
+ return &projectorRegistration{
+ kind: bundleActivationKind,
+ dependsOn: []ResourceKind{bundleKind},
+ build: func(ctx context.Context, input projectionInput) (ProjectionPlan, error) {
+ if input.kind.Normalize() != bundleActivationKind {
+ return nil, fmt.Errorf(
+ "%w: bundle activation projector build expected kind %q, got %q",
+ ErrValidation,
+ bundleActivationKind,
+ input.kind,
+ )
+ }
+ if input.revision < 0 {
+ return nil, fmt.Errorf("%w: revision cannot be negative: %d", ErrValidation, input.revision)
+ }
+ if err := validateBundleActivationDependencies(input.dependencies); err != nil {
+ return nil, err
+ }
+
+ activations, err := decodeTypedRecords(ctx, activationCodec, input.records)
+ if err != nil {
+ return nil, err
+ }
+ bundles, err := decodeTypedRecords(ctx, bundleCodec, input.dependencies[bundleKind])
+ if err != nil {
+ return nil, err
+ }
+ return projector.Build(ctx, activations, bundles)
+ },
+ apply: projector.Apply,
+ }, nil
+}
+
+func validateBundleActivationDependencies(dependencies map[ResourceKind][]RawRecord) error {
+ for kind, records := range dependencies {
+ if kind.Normalize() == bundleKind {
+ continue
+ }
+ if len(records) == 0 {
+ continue
+ }
+ return fmt.Errorf(
+ "%w: bundle activation projector does not accept dependency kind %q",
+ ErrValidation,
+ kind,
+ )
+ }
+ return nil
+}
+
+func unwrapProjectorRegistration(registration ProjectorRegistration) (projector, error) {
+ if registration == nil {
+ return nil, errors.New("resources: projector registration is required")
+ }
+
+ projector, ok := registration.(projector)
+ if !ok {
+ return nil, errors.New("resources: projector registration does not implement internal projector")
+ }
+ return projector, nil
+}
diff --git a/internal/resources/reconcile.go b/internal/resources/reconcile.go
new file mode 100644
index 000000000..fd85dbb72
--- /dev/null
+++ b/internal/resources/reconcile.go
@@ -0,0 +1,1044 @@
+package resources
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ defaultReconcileCoalesceWindow = 50 * time.Millisecond
+ defaultReconcileTimeout = 5 * time.Second
+ defaultReconcileFailureThreshold = 3
+ defaultReconcileDegradedBackoff = 500 * time.Millisecond
+)
+
+// ReconcileReason identifies why one kind was scheduled.
+type ReconcileReason string
+
+const (
+ ReconcileReasonBoot ReconcileReason = "boot"
+ ReconcileReasonWrite ReconcileReason = "write"
+ ReconcileReasonDependency ReconcileReason = "dependency"
+)
+
+// Normalize returns the canonical trimmed reason.
+func (r ReconcileReason) Normalize() ReconcileReason {
+ return ReconcileReason(strings.TrimSpace(string(r)))
+}
+
+// Validate reports whether the reconcile reason is supported.
+func (r ReconcileReason) Validate(path string) error {
+ switch r.Normalize() {
+ case ReconcileReasonBoot, ReconcileReasonWrite, ReconcileReasonDependency:
+ return nil
+ default:
+ return fmt.Errorf(
+ "%w: %s must be %q, %q, or %q: %q",
+ ErrValidation,
+ path,
+ ReconcileReasonBoot,
+ ReconcileReasonWrite,
+ ReconcileReasonDependency,
+ r,
+ )
+ }
+}
+
+// ReconcileDriver drives boot-time and post-commit resource projection.
+type ReconcileDriver interface {
+ Trigger(ctx context.Context, kind ResourceKind, reason ReconcileReason) error
+ RunBoot(ctx context.Context) error
+ Close(ctx context.Context) error
+}
+
+// ReconcileEventType identifies one emitted reconcile event.
+type ReconcileEventType string
+
+const (
+ ReconcileEventRequested ReconcileEventType = "requested"
+ ReconcileEventCoalesced ReconcileEventType = "coalesced"
+ ReconcileEventFailed ReconcileEventType = "failed"
+ ReconcileEventDegraded ReconcileEventType = "degraded"
+ ReconcileEventApplied ReconcileEventType = "applied"
+)
+
+// ReconcileEvent carries one metric-friendly reconcile observation.
+type ReconcileEvent struct {
+ Type ReconcileEventType
+ Kind ResourceKind
+ Reason ReconcileReason
+ Duration time.Duration
+ Revision int64
+ Operations int
+ ConsecutiveFailures int
+ DegradedUntil time.Time
+ Err error
+}
+
+// ReconcileEventSink receives reconcile lifecycle events for metrics and observability wiring.
+type ReconcileEventSink interface {
+ ObserveReconcileEvent(ctx context.Context, event ReconcileEvent)
+}
+
+// ReconcileHealthStatus captures the scheduler health state for one kind.
+type ReconcileHealthStatus string
+
+const (
+ ReconcileHealthStatusHealthy ReconcileHealthStatus = "healthy"
+ ReconcileHealthStatusFailing ReconcileHealthStatus = "failing"
+ ReconcileHealthStatusDegraded ReconcileHealthStatus = "degraded"
+)
+
+// ReconcileHealth captures the current health state for one projected kind.
+type ReconcileHealth struct {
+ Kind ResourceKind
+ Status ReconcileHealthStatus
+ ConsecutiveFailures int
+ DegradedUntil time.Time
+ LastError error
+}
+
+// ReconcileHealthSink receives kind-health updates from the driver.
+type ReconcileHealthSink interface {
+ ReportReconcileHealth(ctx context.Context, health ReconcileHealth)
+}
+
+// ReconcileOption configures a reconcile driver instance.
+type ReconcileOption func(*reconcileDriver)
+
+// WithReconcileLogger overrides the driver logger.
+func WithReconcileLogger(logger *slog.Logger) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if logger != nil {
+ d.logger = logger
+ }
+ }
+}
+
+// WithReconcileNow overrides the clock used by the driver.
+func WithReconcileNow(now func() time.Time) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if now != nil {
+ d.now = now
+ }
+ }
+}
+
+// WithReconcileCoalesceWindow overrides the per-kind rerun coalescing window.
+func WithReconcileCoalesceWindow(window time.Duration) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if window > 0 {
+ d.coalesceWindow = window
+ }
+ }
+}
+
+// WithReconcileTimeout overrides the default per-kind reconcile timeout.
+func WithReconcileTimeout(timeout time.Duration) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if timeout > 0 {
+ d.defaultTimeout = timeout
+ }
+ }
+}
+
+// WithReconcileKindTimeout overrides the timeout for one kind.
+func WithReconcileKindTimeout(kind ResourceKind, timeout time.Duration) ReconcileOption {
+ return func(d *reconcileDriver) {
+ normalizedKind := kind.Normalize()
+ if normalizedKind == "" || timeout <= 0 {
+ return
+ }
+ if d.kindTimeouts == nil {
+ d.kindTimeouts = make(map[ResourceKind]time.Duration)
+ }
+ d.kindTimeouts[normalizedKind] = timeout
+ }
+}
+
+// WithReconcileKindTimeouts overrides timeouts for multiple kinds.
+func WithReconcileKindTimeouts(timeouts map[ResourceKind]time.Duration) ReconcileOption {
+ return func(d *reconcileDriver) {
+ for kind, timeout := range timeouts {
+ WithReconcileKindTimeout(kind, timeout)(d)
+ }
+ }
+}
+
+// WithReconcileFailureThreshold overrides the failure count that opens the degraded circuit.
+func WithReconcileFailureThreshold(threshold int) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if threshold > 0 {
+ d.failureThreshold = threshold
+ }
+ }
+}
+
+// WithReconcileDegradedBackoff overrides the degraded-circuit backoff.
+func WithReconcileDegradedBackoff(backoff time.Duration) ReconcileOption {
+ return func(d *reconcileDriver) {
+ if backoff > 0 {
+ d.degradedBackoff = backoff
+ }
+ }
+}
+
+// WithReconcileEventSink wires a metric-friendly event sink into the driver.
+func WithReconcileEventSink(sink ReconcileEventSink) ReconcileOption {
+ return func(d *reconcileDriver) {
+ d.eventSink = sink
+ }
+}
+
+// WithReconcileHealthSink wires a health sink into the driver.
+func WithReconcileHealthSink(sink ReconcileHealthSink) ReconcileOption {
+ return func(d *reconcileDriver) {
+ d.healthSink = sink
+ }
+}
+
+type reconcileDriver struct {
+ raw RawStore
+ actor MutationActor
+
+ logger *slog.Logger
+ now func() time.Time
+ coalesceWindow time.Duration
+ defaultTimeout time.Duration
+ kindTimeouts map[ResourceKind]time.Duration
+ failureThreshold int
+ degradedBackoff time.Duration
+ eventSink ReconcileEventSink
+ healthSink ReconcileHealthSink
+
+ mu sync.Mutex
+ closed bool
+ queue []ResourceKind
+ kindStates map[ResourceKind]*reconcileKindState
+
+ projectors map[ResourceKind]projector
+ dependents map[ResourceKind][]ResourceKind
+ bootOrder []ResourceKind
+ topoErr error
+ topoRank map[ResourceKind]int
+ workerCtx context.Context
+ workerCancel context.CancelFunc
+ notifyCh chan struct{}
+ doneCh chan struct{}
+}
+
+type reconcileKindState struct {
+ pending bool
+ running bool
+ dirty bool
+ pendingReason ReconcileReason
+ dirtyReason ReconcileReason
+ readyAt time.Time
+ degradedUntil time.Time
+ consecutiveFailures int
+}
+
+type reconcilePassResult struct {
+ reason ReconcileReason
+ revision int64
+ operations int
+ duration time.Duration
+ err error
+}
+
+var _ ReconcileDriver = (*reconcileDriver)(nil)
+
+// NewReconcileDriver constructs the topology-aware reconcile scheduler.
+func NewReconcileDriver(
+ raw RawStore,
+ actor MutationActor,
+ registrations []ProjectorRegistration,
+ opts ...ReconcileOption,
+) (ReconcileDriver, error) {
+ projectors, err := buildProjectorSet(registrations)
+ if err != nil {
+ return nil, err
+ }
+ if raw == nil && len(projectors) > 0 {
+ return nil, errors.New("resources: raw store is required when projectors are registered")
+ }
+
+ normalizedActor := actor
+ if raw != nil || len(projectors) > 0 {
+ normalizedActor, err = normalizeActor(actor)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ driver := &reconcileDriver{
+ raw: raw,
+ actor: normalizedActor,
+ logger: slog.Default(),
+ now: func() time.Time { return time.Now().UTC() },
+ coalesceWindow: defaultReconcileCoalesceWindow,
+ defaultTimeout: defaultReconcileTimeout,
+ kindTimeouts: make(map[ResourceKind]time.Duration),
+ failureThreshold: defaultReconcileFailureThreshold,
+ degradedBackoff: defaultReconcileDegradedBackoff,
+ kindStates: make(map[ResourceKind]*reconcileKindState, len(projectors)),
+ projectors: projectors,
+ dependents: make(map[ResourceKind][]ResourceKind),
+ topoRank: make(map[ResourceKind]int, len(projectors)),
+ notifyCh: make(chan struct{}, 1),
+ doneCh: make(chan struct{}),
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(driver)
+ }
+ }
+ if driver.logger == nil {
+ driver.logger = slog.Default()
+ }
+ if driver.now == nil {
+ return nil, errors.New("resources: reconcile clock is required")
+ }
+ if driver.coalesceWindow <= 0 {
+ return nil, errors.New("resources: reconcile coalesce window must be positive")
+ }
+ if driver.defaultTimeout <= 0 {
+ return nil, errors.New("resources: reconcile timeout must be positive")
+ }
+ if driver.failureThreshold <= 0 {
+ return nil, errors.New("resources: reconcile failure threshold must be positive")
+ }
+ if driver.degradedBackoff <= 0 {
+ return nil, errors.New("resources: reconcile degraded backoff must be positive")
+ }
+
+ driver.bootOrder, driver.dependents, driver.topoRank, driver.topoErr = buildReconcileTopology(projectors)
+ for kind := range projectors {
+ driver.kindStates[kind] = &reconcileKindState{}
+ }
+
+ driver.workerCtx, driver.workerCancel = context.WithCancel(context.Background())
+ go driver.run()
+ return driver, nil
+}
+
+func buildProjectorSet(registrations []ProjectorRegistration) (map[ResourceKind]projector, error) {
+ projectors := make(map[ResourceKind]projector, len(registrations))
+ for _, registration := range registrations {
+ projector, err := unwrapProjectorRegistration(registration)
+ if err != nil {
+ return nil, err
+ }
+ kind := projector.Kind().Normalize()
+ if err := kind.Validate("projector.kind"); err != nil {
+ return nil, err
+ }
+ if _, exists := projectors[kind]; exists {
+ return nil, fmt.Errorf("%w: duplicate projector registration for kind %q", ErrConflict, kind)
+ }
+ projectors[kind] = projector
+ }
+ return projectors, nil
+}
+
+func buildReconcileTopology(
+ projectors map[ResourceKind]projector,
+) ([]ResourceKind, map[ResourceKind][]ResourceKind, map[ResourceKind]int, error) {
+ dependents := make(map[ResourceKind][]ResourceKind, len(projectors))
+ indegree := make(map[ResourceKind]int, len(projectors))
+ for kind := range projectors {
+ indegree[kind] = 0
+ }
+
+ for kind, projector := range projectors {
+ for _, dependency := range normalizeKinds(projector.DependsOn()) {
+ if dependency == "" {
+ continue
+ }
+ if _, ok := projectors[dependency]; !ok {
+ continue
+ }
+ indegree[kind]++
+ dependents[dependency] = append(dependents[dependency], kind)
+ }
+ }
+
+ for dependencyKind := range dependents {
+ sort.Slice(dependents[dependencyKind], func(i int, j int) bool {
+ return string(dependents[dependencyKind][i]) < string(dependents[dependencyKind][j])
+ })
+ }
+
+ ready := make([]ResourceKind, 0, len(projectors))
+ for kind, count := range indegree {
+ if count == 0 {
+ ready = append(ready, kind)
+ }
+ }
+ sort.Slice(ready, func(i int, j int) bool {
+ return string(ready[i]) < string(ready[j])
+ })
+
+ order := make([]ResourceKind, 0, len(projectors))
+ for len(ready) > 0 {
+ kind := ready[0]
+ ready = ready[1:]
+ order = append(order, kind)
+
+ for _, dependent := range dependents[kind] {
+ indegree[dependent]--
+ if indegree[dependent] == 0 {
+ ready = append(ready, dependent)
+ }
+ }
+ sort.Slice(ready, func(i int, j int) bool {
+ return string(ready[i]) < string(ready[j])
+ })
+ }
+
+ rank := make(map[ResourceKind]int, len(projectors))
+ for idx, kind := range order {
+ rank[kind] = idx
+ }
+
+ if len(order) != len(projectors) {
+ return nil, dependents, rank, fmt.Errorf("%w: reconcile projector dependency cycle detected", ErrValidation)
+ }
+
+ for dependencyKind := range dependents {
+ sort.Slice(dependents[dependencyKind], func(i int, j int) bool {
+ left := dependents[dependencyKind][i]
+ right := dependents[dependencyKind][j]
+ return rank[left] < rank[right]
+ })
+ }
+
+ return order, dependents, rank, nil
+}
+
+func (d *reconcileDriver) Trigger(ctx context.Context, kind ResourceKind, reason ReconcileReason) error {
+ if ctx == nil {
+ return errors.New("resources: reconcile trigger context is required")
+ }
+ if d.topoErr != nil {
+ return d.topoErr
+ }
+
+ normalizedKind := kind.Normalize()
+ if err := normalizedKind.Validate("kind"); err != nil {
+ return err
+ }
+ normalizedReason := reason.Normalize()
+ if err := normalizedReason.Validate("reason"); err != nil {
+ return err
+ }
+
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ if d.closed {
+ return errors.New("resources: reconcile driver is closed")
+ }
+ if _, ok := d.projectors[normalizedKind]; !ok {
+ return fmt.Errorf("%w: reconcile kind %q is not registered", ErrValidation, normalizedKind)
+ }
+
+ for idx, scheduledKind := range d.scheduleCascade(normalizedKind) {
+ scheduledReason := normalizedReason
+ if idx > 0 {
+ scheduledReason = ReconcileReasonDependency
+ }
+ d.emitEventLocked(ctx, ReconcileEvent{
+ Type: ReconcileEventRequested,
+ Kind: scheduledKind,
+ Reason: scheduledReason,
+ })
+ d.enqueueLocked(scheduledKind, scheduledReason)
+ }
+ d.notifyLocked()
+ return nil
+}
+
+func (d *reconcileDriver) RunBoot(ctx context.Context) error {
+ if ctx == nil {
+ return errors.New("resources: reconcile boot context is required")
+ }
+ if d.topoErr != nil {
+ return d.topoErr
+ }
+
+ d.mu.Lock()
+ if d.closed {
+ d.mu.Unlock()
+ return errors.New("resources: reconcile driver is closed")
+ }
+ order := append([]ResourceKind(nil), d.bootOrder...)
+ d.mu.Unlock()
+
+ for _, kind := range order {
+ d.emitEvent(ctx, ReconcileEvent{
+ Type: ReconcileEventRequested,
+ Kind: kind,
+ Reason: ReconcileReasonBoot,
+ })
+ result := d.runPass(ctx, kind, ReconcileReasonBoot)
+ if result.err != nil {
+ d.handleBootFailure(ctx, kind, result)
+ return result.err
+ }
+ d.handleBootSuccess(ctx, kind, result)
+ }
+ return nil
+}
+
+func (d *reconcileDriver) Close(ctx context.Context) error {
+ if ctx == nil {
+ return errors.New("resources: reconcile close context is required")
+ }
+
+ d.mu.Lock()
+ if !d.closed {
+ d.closed = true
+ d.queue = nil
+ for _, state := range d.kindStates {
+ state.pending = false
+ state.dirty = false
+ state.pendingReason = ""
+ state.dirtyReason = ""
+ state.readyAt = time.Time{}
+ }
+ d.workerCancel()
+ d.notifyLocked()
+ }
+ doneCh := d.doneCh
+ d.mu.Unlock()
+
+ select {
+ case <-doneCh:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (d *reconcileDriver) scheduleCascade(root ResourceKind) []ResourceKind {
+ ordered := []ResourceKind{root}
+ visited := map[ResourceKind]struct{}{
+ root: {},
+ }
+ var reachable []ResourceKind
+ queue := append([]ResourceKind(nil), d.dependents[root]...)
+ for len(queue) > 0 {
+ kind := queue[0]
+ queue = queue[1:]
+ if _, seen := visited[kind]; seen {
+ continue
+ }
+ visited[kind] = struct{}{}
+ reachable = append(reachable, kind)
+ queue = append(queue, d.dependents[kind]...)
+ }
+
+ sort.Slice(reachable, func(i int, j int) bool {
+ left := reachable[i]
+ right := reachable[j]
+ leftRank, leftOK := d.topoRank[left]
+ rightRank, rightOK := d.topoRank[right]
+ switch {
+ case leftOK && rightOK:
+ return leftRank < rightRank
+ case leftOK:
+ return true
+ case rightOK:
+ return false
+ default:
+ return string(left) < string(right)
+ }
+ })
+
+ return append(ordered, reachable...)
+}
+
+func (d *reconcileDriver) enqueueLocked(kind ResourceKind, reason ReconcileReason) {
+ state := d.kindStates[kind]
+ if state == nil {
+ return
+ }
+
+ if state.running {
+ state.dirty = true
+ state.dirtyReason = reason
+ d.emitEventLocked(context.Background(), ReconcileEvent{
+ Type: ReconcileEventCoalesced,
+ Kind: kind,
+ Reason: reason,
+ })
+ return
+ }
+
+ if state.pending {
+ if reason == ReconcileReasonWrite && state.degradedUntil.After(d.now()) {
+ state.degradedUntil = time.Time{}
+ state.readyAt = time.Time{}
+ }
+ state.pendingReason = reason
+ d.emitEventLocked(context.Background(), ReconcileEvent{
+ Type: ReconcileEventCoalesced,
+ Kind: kind,
+ Reason: reason,
+ })
+ return
+ }
+
+ state.pending = true
+ state.pendingReason = reason
+ state.readyAt = time.Time{}
+ d.queue = append(d.queue, kind)
+}
+
+func (d *reconcileDriver) run() {
+ defer close(d.doneCh)
+
+ for {
+ kind, reason, waitUntil, ok := d.nextPending()
+ if !ok {
+ if d.shouldExit() {
+ return
+ }
+ if !d.waitForWork(waitUntil) {
+ return
+ }
+ continue
+ }
+
+ result := d.runPass(d.workerCtx, kind, reason)
+ d.finishAsyncPass(kind, result)
+ }
+}
+
+func (d *reconcileDriver) shouldExit() bool {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if !d.closed {
+ return false
+ }
+ for _, state := range d.kindStates {
+ if state.running {
+ return false
+ }
+ }
+ return true
+}
+
+func (d *reconcileDriver) nextPending() (ResourceKind, ReconcileReason, time.Time, bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ now := d.now()
+ var earliest time.Time
+ for idx, kind := range d.queue {
+ state := d.kindStates[kind]
+ if state == nil || !state.pending {
+ continue
+ }
+
+ notBefore := state.readyAt
+ if state.degradedUntil.After(notBefore) {
+ notBefore = state.degradedUntil
+ }
+ if notBefore.After(now) {
+ if earliest.IsZero() || notBefore.Before(earliest) {
+ earliest = notBefore
+ }
+ continue
+ }
+
+ d.queue = append(d.queue[:idx], d.queue[idx+1:]...)
+ state.pending = false
+ state.running = true
+ reason := state.pendingReason
+ state.pendingReason = ""
+ return kind, reason, time.Time{}, true
+ }
+
+ return "", "", earliest, false
+}
+
+func (d *reconcileDriver) waitForWork(waitUntil time.Time) bool {
+ if waitUntil.IsZero() {
+ select {
+ case <-d.notifyCh:
+ return true
+ case <-d.workerCtx.Done():
+ return false
+ }
+ }
+
+ delay := time.Until(waitUntil)
+ if delay <= 0 {
+ return true
+ }
+
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return true
+ case <-d.notifyCh:
+ return true
+ case <-d.workerCtx.Done():
+ return false
+ }
+}
+
+func (d *reconcileDriver) runPass(
+ ctx context.Context,
+ kind ResourceKind,
+ reason ReconcileReason,
+) reconcilePassResult {
+ startedAt := d.now()
+
+ timeout := d.defaultTimeout
+ if override, ok := d.kindTimeouts[kind]; ok && override > 0 {
+ timeout = override
+ }
+
+ passCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ input, err := d.buildProjectionInput(passCtx, kind)
+ if err != nil {
+ return reconcilePassResult{
+ reason: reason,
+ duration: d.now().Sub(startedAt),
+ err: err,
+ }
+ }
+
+ projector := d.projectors[kind]
+ plan, err := projector.Build(passCtx, input)
+ if err == nil {
+ err = projector.Apply(passCtx, plan)
+ }
+ if err != nil {
+ return reconcilePassResult{
+ reason: reason,
+ duration: d.now().Sub(startedAt),
+ err: err,
+ }
+ }
+
+ return reconcilePassResult{
+ reason: reason,
+ revision: plan.Revision(),
+ operations: plan.OperationCount(),
+ duration: d.now().Sub(startedAt),
+ }
+}
+
+func (d *reconcileDriver) buildProjectionInput(ctx context.Context, kind ResourceKind) (projectionInput, error) {
+ if d.raw == nil {
+ return projectionInput{}, errors.New("resources: raw store is required for registered projectors")
+ }
+
+ records, err := d.raw.ListRaw(ctx, d.actor, ResourceFilter{Kind: kind})
+ if err != nil {
+ return projectionInput{}, fmt.Errorf("resources: list reconcile records for %q: %w", kind, err)
+ }
+
+ projector := d.projectors[kind]
+ dependencies := make(map[ResourceKind][]RawRecord, len(projector.DependsOn()))
+ for _, dependency := range normalizeKinds(projector.DependsOn()) {
+ dependencyRecords, depErr := d.raw.ListRaw(ctx, d.actor, ResourceFilter{Kind: dependency})
+ if depErr != nil {
+ return projectionInput{}, fmt.Errorf(
+ "resources: list reconcile dependency records for %q -> %q: %w",
+ kind,
+ dependency,
+ depErr,
+ )
+ }
+ dependencies[dependency] = dependencyRecords
+ }
+
+ return projectionInput{
+ kind: kind,
+ revision: maxRecordVersion(records),
+ records: records,
+ dependencies: dependencies,
+ }, nil
+}
+
+func maxRecordVersion(records []RawRecord) int64 {
+ var revision int64
+ for _, record := range records {
+ if record.Version > revision {
+ revision = record.Version
+ }
+ }
+ return revision
+}
+
+func (d *reconcileDriver) finishAsyncPass(kind ResourceKind, result reconcilePassResult) {
+ health, emitDegraded, ok := d.updateAsyncPassState(kind, result)
+ if !ok {
+ return
+ }
+
+ if result.err != nil {
+ d.recordAsyncFailure(kind, result, health, emitDegraded)
+ d.notify()
+ return
+ }
+
+ d.recordAsyncSuccess(kind, result, health)
+ d.notify()
+}
+
+func (d *reconcileDriver) updateAsyncPassState(
+ kind ResourceKind,
+ result reconcilePassResult,
+) (ReconcileHealth, bool, bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ state := d.kindStates[kind]
+ if state == nil {
+ return ReconcileHealth{}, false, false
+ }
+ state.running = false
+
+ dirty := state.dirty
+ dirtyReason := state.dirtyReason
+ state.dirty = false
+ state.dirtyReason = ""
+
+ if result.err != nil {
+ state.consecutiveFailures++
+ if state.consecutiveFailures >= d.failureThreshold {
+ state.degradedUntil = d.now().Add(d.degradedBackoff)
+ }
+ } else {
+ state.consecutiveFailures = 0
+ state.degradedUntil = time.Time{}
+ }
+
+ var health ReconcileHealth
+ var emitDegraded bool
+ if result.err != nil {
+ health = ReconcileHealth{
+ Kind: kind,
+ Status: ReconcileHealthStatusFailing,
+ ConsecutiveFailures: state.consecutiveFailures,
+ LastError: result.err,
+ }
+ if !state.degradedUntil.IsZero() {
+ health.Status = ReconcileHealthStatusDegraded
+ health.DegradedUntil = state.degradedUntil
+ emitDegraded = true
+ }
+ } else {
+ health = ReconcileHealth{
+ Kind: kind,
+ Status: ReconcileHealthStatusHealthy,
+ }
+ }
+
+ if dirty && !d.closed {
+ state.pending = true
+ state.pendingReason = dirtyReason
+ state.readyAt = d.now().Add(d.coalesceWindow)
+ d.queue = append([]ResourceKind{kind}, d.queue...)
+ }
+
+ return health, emitDegraded, true
+}
+
+func (d *reconcileDriver) recordAsyncFailure(
+ kind ResourceKind,
+ result reconcilePassResult,
+ health ReconcileHealth,
+ emitDegraded bool,
+) {
+ d.logger.Error(
+ "resources: reconcile pass failed",
+ "resource_kind",
+ kind,
+ "reconcile_reason",
+ result.reason,
+ "consecutive_failures",
+ health.ConsecutiveFailures,
+ "degraded_until",
+ health.DegradedUntil,
+ "error",
+ result.err,
+ )
+ d.emitEvent(context.Background(), ReconcileEvent{
+ Type: ReconcileEventFailed,
+ Kind: kind,
+ Reason: result.reason,
+ Duration: result.duration,
+ ConsecutiveFailures: health.ConsecutiveFailures,
+ Err: result.err,
+ })
+ if emitDegraded {
+ d.emitEvent(context.Background(), ReconcileEvent{
+ Type: ReconcileEventDegraded,
+ Kind: kind,
+ Reason: result.reason,
+ ConsecutiveFailures: health.ConsecutiveFailures,
+ DegradedUntil: health.DegradedUntil,
+ Err: result.err,
+ })
+ }
+ d.reportHealth(context.Background(), health)
+}
+
+func (d *reconcileDriver) recordAsyncSuccess(
+ kind ResourceKind,
+ result reconcilePassResult,
+ health ReconcileHealth,
+) {
+ d.logger.Debug(
+ "resources: reconcile pass applied",
+ "resource_kind",
+ kind,
+ "reconcile_reason",
+ result.reason,
+ "revision",
+ result.revision,
+ "operations",
+ result.operations,
+ "duration",
+ result.duration,
+ )
+ d.emitEvent(context.Background(), ReconcileEvent{
+ Type: ReconcileEventApplied,
+ Kind: kind,
+ Reason: result.reason,
+ Duration: result.duration,
+ Revision: result.revision,
+ Operations: result.operations,
+ })
+ d.reportHealth(context.Background(), health)
+}
+
+func (d *reconcileDriver) handleBootFailure(ctx context.Context, kind ResourceKind, result reconcilePassResult) {
+ consecutiveFailures := 1
+ degradedUntil := time.Time{}
+ if d.failureThreshold <= 1 {
+ degradedUntil = d.now().Add(d.degradedBackoff)
+ }
+
+ status := ReconcileHealthStatusFailing
+ if !degradedUntil.IsZero() {
+ status = ReconcileHealthStatusDegraded
+ }
+ health := ReconcileHealth{
+ Kind: kind,
+ Status: status,
+ ConsecutiveFailures: consecutiveFailures,
+ DegradedUntil: degradedUntil,
+ LastError: result.err,
+ }
+
+ d.logger.Error(
+ "resources: boot reconcile failed",
+ "resource_kind",
+ kind,
+ "reconcile_reason",
+ result.reason,
+ "degraded_until",
+ degradedUntil,
+ "error",
+ result.err,
+ )
+ d.emitEvent(ctx, ReconcileEvent{
+ Type: ReconcileEventFailed,
+ Kind: kind,
+ Reason: result.reason,
+ Duration: result.duration,
+ ConsecutiveFailures: consecutiveFailures,
+ Err: result.err,
+ })
+ if !degradedUntil.IsZero() {
+ d.emitEvent(ctx, ReconcileEvent{
+ Type: ReconcileEventDegraded,
+ Kind: kind,
+ Reason: result.reason,
+ ConsecutiveFailures: consecutiveFailures,
+ DegradedUntil: degradedUntil,
+ Err: result.err,
+ })
+ }
+ d.reportHealth(ctx, health)
+}
+
+func (d *reconcileDriver) handleBootSuccess(ctx context.Context, kind ResourceKind, result reconcilePassResult) {
+ d.logger.Debug(
+ "resources: boot reconcile applied",
+ "resource_kind",
+ kind,
+ "reconcile_reason",
+ result.reason,
+ "revision",
+ result.revision,
+ "operations",
+ result.operations,
+ "duration",
+ result.duration,
+ )
+ d.emitEvent(ctx, ReconcileEvent{
+ Type: ReconcileEventApplied,
+ Kind: kind,
+ Reason: result.reason,
+ Duration: result.duration,
+ Revision: result.revision,
+ Operations: result.operations,
+ })
+ d.reportHealth(ctx, ReconcileHealth{
+ Kind: kind,
+ Status: ReconcileHealthStatusHealthy,
+ })
+}
+
+func (d *reconcileDriver) emitEvent(ctx context.Context, event ReconcileEvent) {
+ if d.eventSink == nil {
+ return
+ }
+ d.eventSink.ObserveReconcileEvent(ctx, event)
+}
+
+func (d *reconcileDriver) emitEventLocked(ctx context.Context, event ReconcileEvent) {
+ if d.eventSink == nil {
+ return
+ }
+ d.eventSink.ObserveReconcileEvent(ctx, event)
+}
+
+func (d *reconcileDriver) reportHealth(ctx context.Context, health ReconcileHealth) {
+ if d.healthSink == nil {
+ return
+ }
+ d.healthSink.ReportReconcileHealth(ctx, health)
+}
+
+func (d *reconcileDriver) notify() {
+ select {
+ case d.notifyCh <- struct{}{}:
+ default:
+ }
+}
+
+func (d *reconcileDriver) notifyLocked() {
+ select {
+ case d.notifyCh <- struct{}{}:
+ default:
+ }
+}
diff --git a/internal/resources/reconcile_integration_test.go b/internal/resources/reconcile_integration_test.go
new file mode 100644
index 000000000..e82d402a5
--- /dev/null
+++ b/internal/resources/reconcile_integration_test.go
@@ -0,0 +1,233 @@
+//go:build integration
+
+package resources
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestReconcileDriverRunBootTopologyIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+
+ var mu sync.Mutex
+ var order []ResourceKind
+ recordOrder := func(kind ResourceKind) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, kind)
+ }
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(ResourceKind("tool"), nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(ResourceKind("tool"))
+ return testPlan{kind: ResourceKind("tool"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(ResourceKind("agent"), []ResourceKind{ResourceKind("tool")},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(ResourceKind("agent"))
+ return testPlan{kind: ResourceKind("agent"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(ResourceKind("bundle.activation"), []ResourceKind{ResourceKind("agent")},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(ResourceKind("bundle.activation"))
+ return testPlan{kind: ResourceKind("bundle.activation"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.RunBoot(testutil.Context(t)); err != nil {
+ t.Fatalf("RunBoot() error = %v", err)
+ }
+
+ mu.Lock()
+ gotOrder := append([]ResourceKind(nil), order...)
+ mu.Unlock()
+ wantOrder := []ResourceKind{ResourceKind("tool"), ResourceKind("agent"), ResourceKind("bundle.activation")}
+ if len(gotOrder) != len(wantOrder) {
+ t.Fatalf("boot order = %#v, want %#v", gotOrder, wantOrder)
+ }
+ for idx := range wantOrder {
+ if gotOrder[idx] != wantOrder[idx] {
+ t.Fatalf("boot order[%d] = %q, want %q (full=%#v)", idx, gotOrder[idx], wantOrder[idx], gotOrder)
+ }
+ }
+}
+
+func TestReconcileDriverRunBootRejectsInvalidGraphIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(ResourceKind("tool"), []ResourceKind{ResourceKind("agent")},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ return testPlan{kind: ResourceKind("tool"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(ResourceKind("agent"), []ResourceKind{ResourceKind("tool")},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ return testPlan{kind: ResourceKind("agent"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.RunBoot(testutil.Context(t)); !errors.Is(err, ErrValidation) {
+ t.Fatalf("RunBoot() error = %v, want ErrValidation", err)
+ }
+}
+
+func TestReconcileDriverWriteStormCoalescesIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ var mu sync.Mutex
+ buildCalls := 0
+ firstBuildStarted := make(chan struct{})
+ releaseFirstBuild := make(chan struct{})
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ mu.Lock()
+ buildCalls++
+ call := buildCalls
+ mu.Unlock()
+
+ if call == 1 {
+ close(firstBuildStarted)
+ <-releaseFirstBuild
+ }
+ return testPlan{kind: testResourceKind, revision: int64(call), operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ WithReconcileCoalesceWindow(25*time.Millisecond),
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(first) error = %v", err)
+ }
+ <-firstBuildStarted
+
+ for i := 0; i < 25; i++ {
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(storm %d) error = %v", i, err)
+ }
+ }
+
+ close(releaseFirstBuild)
+
+ waitForCondition(t, time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return buildCalls == 2
+ })
+ time.Sleep(100 * time.Millisecond)
+
+ mu.Lock()
+ gotBuildCalls := buildCalls
+ mu.Unlock()
+ if gotBuildCalls != 2 {
+ t.Fatalf("buildCalls = %d, want 2", gotBuildCalls)
+ }
+}
+
+func TestReconcileDriverCloseCancelsInFlightWorkIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ buildStarted := make(chan struct{})
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(ctx context.Context, _ projectionInput) (ProjectionPlan, error) {
+ close(buildStarted)
+ <-ctx.Done()
+ return nil, ctx.Err()
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger() error = %v", err)
+ }
+ <-buildStarted
+
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := driver.Close(closeCtx); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err == nil {
+ t.Fatal("Trigger(after Close) error = nil, want non-nil")
+ }
+}
diff --git a/internal/resources/reconcile_test.go b/internal/resources/reconcile_test.go
new file mode 100644
index 000000000..d59c37dbc
--- /dev/null
+++ b/internal/resources/reconcile_test.go
@@ -0,0 +1,503 @@
+package resources
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+type recordingReconcileEventSink struct {
+ mu sync.Mutex
+ events []ReconcileEvent
+}
+
+func (s *recordingReconcileEventSink) ObserveReconcileEvent(_ context.Context, event ReconcileEvent) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.events = append(s.events, event)
+}
+
+func (s *recordingReconcileEventSink) count(eventType ReconcileEventType) int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ count := 0
+ for _, event := range s.events {
+ if event.Type == eventType {
+ count++
+ }
+ }
+ return count
+}
+
+type recordingReconcileHealthSink struct {
+ mu sync.Mutex
+ updates []ReconcileHealth
+}
+
+func (s *recordingReconcileHealthSink) ReportReconcileHealth(_ context.Context, health ReconcileHealth) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.updates = append(s.updates, health)
+}
+
+func (s *recordingReconcileHealthSink) latest() ReconcileHealth {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if len(s.updates) == 0 {
+ return ReconcileHealth{}
+ }
+ return s.updates[len(s.updates)-1]
+}
+
+func newTestProjectorRegistration(
+ kind ResourceKind,
+ dependsOn []ResourceKind,
+ build func(context.Context, projectionInput) (ProjectionPlan, error),
+ apply func(context.Context, ProjectionPlan) error,
+) ProjectorRegistration {
+ return &projectorRegistration{
+ kind: kind.Normalize(),
+ dependsOn: normalizeKinds(dependsOn),
+ build: build,
+ apply: apply,
+ }
+}
+
+func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if fn() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("condition not satisfied before timeout")
+}
+
+func TestReconcileDriverSingleFlightCoalescesSameKind(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ eventSink := &recordingReconcileEventSink{}
+
+ var mu sync.Mutex
+ buildCalls := 0
+ firstBuildStarted := make(chan struct{})
+ releaseFirstBuild := make(chan struct{})
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(_ context.Context, _ projectionInput) (ProjectionPlan, error) {
+ mu.Lock()
+ buildCalls++
+ call := buildCalls
+ mu.Unlock()
+
+ if call == 1 {
+ close(firstBuildStarted)
+ <-releaseFirstBuild
+ }
+ return testPlan{kind: testResourceKind, revision: int64(call), operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ WithReconcileEventSink(eventSink),
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(first) error = %v", err)
+ }
+ <-firstBuildStarted
+
+ for i := range 4 {
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(coalesced %d) error = %v", i, err)
+ }
+ }
+
+ close(releaseFirstBuild)
+
+ waitForCondition(t, time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return buildCalls == 2
+ })
+ time.Sleep(100 * time.Millisecond)
+
+ mu.Lock()
+ gotBuildCalls := buildCalls
+ mu.Unlock()
+ if gotBuildCalls != 2 {
+ t.Fatalf("buildCalls = %d, want 2", gotBuildCalls)
+ }
+ if got, wantMin := eventSink.count(ReconcileEventCoalesced), 1; got < wantMin {
+ t.Fatalf("coalesced events = %d, want at least %d", got, wantMin)
+ }
+}
+
+func TestReconcileDriverPropagatesTimeoutToProjectorContexts(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ timeout := 30 * time.Millisecond
+
+ var mu sync.Mutex
+ var capturedDeadline time.Time
+ var buildErr error
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(ctx context.Context, _ projectionInput) (ProjectionPlan, error) {
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ t.Fatal("Build() context missing deadline")
+ }
+
+ mu.Lock()
+ capturedDeadline = deadline
+ mu.Unlock()
+
+ <-ctx.Done()
+ buildErr = ctx.Err()
+ return nil, ctx.Err()
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ WithReconcileTimeout(timeout),
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ startedAt := time.Now()
+ err = driver.RunBoot(testutil.Context(t))
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("RunBoot() error = %v, want context.DeadlineExceeded", err)
+ }
+ if !errors.Is(buildErr, context.DeadlineExceeded) {
+ t.Fatalf("Build() error = %v, want context.DeadlineExceeded", buildErr)
+ }
+
+ mu.Lock()
+ deadline := capturedDeadline
+ mu.Unlock()
+ if deadline.IsZero() {
+ t.Fatal("captured deadline was not set")
+ }
+
+ remaining := deadline.Sub(startedAt)
+ if remaining <= 0 || remaining > timeout+50*time.Millisecond {
+ t.Fatalf("deadline offset = %s, want within (0,%s]", remaining, timeout+50*time.Millisecond)
+ }
+}
+
+func TestReconcileDriverOpensDegradedCircuitAndWaitsForFreshWrite(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ eventSink := &recordingReconcileEventSink{}
+ healthSink := &recordingReconcileHealthSink{}
+
+ var mu sync.Mutex
+ buildCalls := 0
+ firstBuildStarted := make(chan struct{})
+ releaseFirstBuild := make(chan struct{})
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(_ context.Context, _ projectionInput) (ProjectionPlan, error) {
+ mu.Lock()
+ buildCalls++
+ call := buildCalls
+ mu.Unlock()
+
+ if call == 1 {
+ close(firstBuildStarted)
+ <-releaseFirstBuild
+ }
+ return nil, errors.New("boom")
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ WithReconcileCoalesceWindow(20*time.Millisecond),
+ WithReconcileFailureThreshold(1),
+ WithReconcileDegradedBackoff(time.Hour),
+ WithReconcileEventSink(eventSink),
+ WithReconcileHealthSink(healthSink),
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(first) error = %v", err)
+ }
+ <-firstBuildStarted
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(coalesced) error = %v", err)
+ }
+
+ close(releaseFirstBuild)
+ time.Sleep(100 * time.Millisecond)
+
+ mu.Lock()
+ gotBuildCalls := buildCalls
+ mu.Unlock()
+ if gotBuildCalls != 1 {
+ t.Fatalf("buildCalls after degraded failure = %d, want 1", gotBuildCalls)
+ }
+ if got := eventSink.count(ReconcileEventDegraded); got != 1 {
+ t.Fatalf("degraded events = %d, want 1", got)
+ }
+ if got := healthSink.latest().Status; got != ReconcileHealthStatusDegraded {
+ t.Fatalf("latest health status = %q, want %q", got, ReconcileHealthStatusDegraded)
+ }
+
+ if err := driver.Trigger(ctx, testResourceKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(fresh write) error = %v", err)
+ }
+
+ waitForCondition(t, time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return buildCalls == 2
+ })
+}
+
+func TestReconcileDriverSchedulesReverseDependenciesAfterWritesOnly(t *testing.T) {
+ t.Parallel()
+
+ t.Run("root write fans out to dependents in order", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ var mu sync.Mutex
+ var order []ResourceKind
+ recordOrder := func(kind ResourceKind) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, kind)
+ }
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(bundleKind, nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(bundleKind)
+ return testPlan{kind: bundleKind, revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(bundleActivationKind, []ResourceKind{bundleKind},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(bundleActivationKind)
+ return testPlan{kind: bundleActivationKind, revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(ResourceKind("automation.job"), nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(ResourceKind("automation.job"))
+ return testPlan{kind: ResourceKind("automation.job"), revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.Trigger(ctx, bundleKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(bundle) error = %v", err)
+ }
+
+ waitForCondition(t, time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(order) == 2
+ })
+
+ mu.Lock()
+ gotOrder := append([]ResourceKind(nil), order...)
+ mu.Unlock()
+ wantOrder := []ResourceKind{bundleKind, bundleActivationKind}
+ if len(gotOrder) != len(wantOrder) {
+ t.Fatalf("order = %#v, want %#v", gotOrder, wantOrder)
+ }
+ for idx := range wantOrder {
+ if gotOrder[idx] != wantOrder[idx] {
+ t.Fatalf("order[%d] = %q, want %q (full=%#v)", idx, gotOrder[idx], wantOrder[idx], gotOrder)
+ }
+ }
+ })
+
+ t.Run("dependent write does not reverse-trigger dependencies", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+
+ var mu sync.Mutex
+ var order []ResourceKind
+ recordOrder := func(kind ResourceKind) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, kind)
+ }
+
+ driver, err := NewReconcileDriver(
+ kernel,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(bundleKind, nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(bundleKind)
+ return testPlan{kind: bundleKind, revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ newTestProjectorRegistration(bundleActivationKind, []ResourceKind{bundleKind},
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ recordOrder(bundleActivationKind)
+ return testPlan{kind: bundleActivationKind, revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+ t.Cleanup(func() {
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if closeErr := driver.Close(closeCtx); closeErr != nil {
+ t.Fatalf("Close() error = %v", closeErr)
+ }
+ })
+
+ if err := driver.Trigger(ctx, bundleActivationKind, ReconcileReasonWrite); err != nil {
+ t.Fatalf("Trigger(bundle.activation) error = %v", err)
+ }
+
+ waitForCondition(t, time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(order) == 1
+ })
+
+ mu.Lock()
+ gotOrder := append([]ResourceKind(nil), order...)
+ mu.Unlock()
+ if len(gotOrder) != 1 || gotOrder[0] != bundleActivationKind {
+ t.Fatalf("order = %#v, want only %q", gotOrder, bundleActivationKind)
+ }
+ })
+}
+
+func TestReconcileDriverValidationAndLifecycleErrors(t *testing.T) {
+ t.Parallel()
+
+ t.Run("registered projectors require raw store", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := NewReconcileDriver(
+ nil,
+ testDaemonActor(),
+ []ProjectorRegistration{
+ newTestProjectorRegistration(testResourceKind, nil,
+ func(context.Context, projectionInput) (ProjectionPlan, error) {
+ return testPlan{kind: testResourceKind, revision: 1, operations: 1}, nil
+ },
+ func(context.Context, ProjectionPlan) error { return nil },
+ ),
+ },
+ )
+ if err == nil {
+ t.Fatal("NewReconcileDriver() error = nil, want raw-store validation failure")
+ }
+ })
+
+ t.Run("closed and unknown kinds are rejected", func(t *testing.T) {
+ t.Parallel()
+
+ driver, err := NewReconcileDriver(nil, MutationActor{}, nil)
+ if err != nil {
+ t.Fatalf("NewReconcileDriver() error = %v", err)
+ }
+
+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := driver.Close(closeCtx); err != nil {
+ t.Fatalf("Close() error = %v", err)
+ }
+
+ if err := driver.Trigger(testutil.Context(t), testResourceKind, ReconcileReasonWrite); err == nil {
+ t.Fatal("Trigger(closed) error = nil, want closed-driver failure")
+ }
+ })
+
+ t.Run("reason validation rejects unsupported values", func(t *testing.T) {
+ t.Parallel()
+
+ if err := ReconcileReason("invalid").Validate("reason"); !errors.Is(err, ErrValidation) {
+ t.Fatalf("ReconcileReason.Validate() error = %v, want ErrValidation", err)
+ }
+ })
+}
diff --git a/internal/resources/schema.go b/internal/resources/schema.go
new file mode 100644
index 000000000..8c420c258
--- /dev/null
+++ b/internal/resources/schema.go
@@ -0,0 +1,42 @@
+package resources
+
+import "slices"
+
+var schemaStatements = []string{
+ `CREATE TABLE IF NOT EXISTS resource_records (
+ kind TEXT NOT NULL,
+ id TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ scope_kind TEXT NOT NULL CHECK (scope_kind IN ('global', 'workspace')),
+ scope_id TEXT,
+ owner_kind TEXT NOT NULL,
+ owner_id TEXT NOT NULL,
+ source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ spec_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (kind, id),
+ CHECK (
+ (scope_kind = 'global' AND scope_id IS NULL) OR
+ (scope_kind = 'workspace' AND scope_id IS NOT NULL)
+ )
+ );`,
+ `CREATE INDEX IF NOT EXISTS idx_resource_kind ON resource_records(kind);`,
+ `CREATE INDEX IF NOT EXISTS idx_resource_scope ON resource_records(scope_kind, scope_id, kind);`,
+ `CREATE INDEX IF NOT EXISTS idx_resource_owner ON resource_records(owner_kind, owner_id, kind);`,
+ `CREATE INDEX IF NOT EXISTS idx_resource_source ON resource_records(source_kind, source_id, kind);`,
+ `CREATE TABLE IF NOT EXISTS resource_source_state (
+ source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ session_nonce TEXT NOT NULL,
+ last_snapshot_version INTEGER NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (source_kind, source_id)
+ );`,
+}
+
+// SchemaStatements returns the canonical SQLite schema bootstrap for the raw resource kernel.
+func SchemaStatements() []string {
+ return slices.Clone(schemaStatements)
+}
diff --git a/internal/resources/typed.go b/internal/resources/typed.go
new file mode 100644
index 000000000..a39c502ac
--- /dev/null
+++ b/internal/resources/typed.go
@@ -0,0 +1,211 @@
+package resources
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Draft is the typed desired-state mutation shape exposed to domain code.
+type Draft[T any] struct {
+ ID string
+ Scope ResourceScope
+ ExpectedVersion int64
+ Spec T
+}
+
+// Record is the typed desired-state record shape exposed to domain code.
+type Record[T any] struct {
+ Kind ResourceKind
+ ID string
+ Version int64
+ Scope ResourceScope
+ Owner ResourceOwner
+ Source ResourceSource
+ Spec T
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Store is the typed CRUD faΓ§ade used by domain code.
+type Store[T any] interface {
+ Put(ctx context.Context, actor MutationActor, draft Draft[T]) (Record[T], error)
+ Delete(ctx context.Context, actor MutationActor, id string, expectedVersion int64) error
+ Get(ctx context.Context, actor MutationActor, id string) (Record[T], error)
+ List(ctx context.Context, actor MutationActor, filter ResourceFilter) ([]Record[T], error)
+}
+
+type typedStore[T any] struct {
+ raw RawStore
+ codec KindCodec[T]
+}
+
+// NewStore constructs a typed store faΓ§ade over the raw persistence kernel.
+func NewStore[T any](raw RawStore, codec KindCodec[T]) (Store[T], error) {
+ if raw == nil {
+ return nil, errors.New("resources: raw store is required")
+ }
+ if _, err := validateCodec(codec); err != nil {
+ return nil, err
+ }
+
+ return &typedStore[T]{
+ raw: raw,
+ codec: codec,
+ }, nil
+}
+
+func (s *typedStore[T]) Put(ctx context.Context, actor MutationActor, draft Draft[T]) (Record[T], error) {
+ if ctx == nil {
+ return Record[T]{}, errors.New("resources: typed put context is required")
+ }
+
+ normalizedDraft, err := normalizeTypedDraft(draft)
+ if err != nil {
+ return Record[T]{}, err
+ }
+
+ specJSON, err := encodeValidatedSpec(ctx, s.codec, normalizedDraft.Scope, normalizedDraft.Spec)
+ if err != nil {
+ return Record[T]{}, err
+ }
+
+ rawRecord, err := s.raw.PutRaw(ctx, actor, RawDraft{
+ Kind: s.codec.Kind(),
+ ID: normalizedDraft.ID,
+ Scope: normalizedDraft.Scope,
+ ExpectedVersion: normalizedDraft.ExpectedVersion,
+ SpecJSON: specJSON,
+ })
+ if err != nil {
+ return Record[T]{}, err
+ }
+
+ return decodeTypedRecord(ctx, s.codec, rawRecord)
+}
+
+func (s *typedStore[T]) Delete(ctx context.Context, actor MutationActor, id string, expectedVersion int64) error {
+ return s.raw.DeleteRaw(ctx, actor, s.codec.Kind(), id, expectedVersion)
+}
+
+func (s *typedStore[T]) Get(ctx context.Context, actor MutationActor, id string) (Record[T], error) {
+ rawRecord, err := s.raw.GetRaw(ctx, actor, s.codec.Kind(), id)
+ if err != nil {
+ return Record[T]{}, err
+ }
+ return decodeTypedRecord(ctx, s.codec, rawRecord)
+}
+
+func (s *typedStore[T]) List(ctx context.Context, actor MutationActor, filter ResourceFilter) ([]Record[T], error) {
+ if filter.Kind != "" && filter.Kind.Normalize() != s.codec.Kind() {
+ return nil, fmt.Errorf(
+ "%w: typed store for kind %q cannot list filter kind %q",
+ ErrValidation,
+ s.codec.Kind(),
+ filter.Kind,
+ )
+ }
+
+ filter.Kind = s.codec.Kind()
+ rawRecords, err := s.raw.ListRaw(ctx, actor, filter)
+ if err != nil {
+ return nil, err
+ }
+
+ records := make([]Record[T], 0, len(rawRecords))
+ for _, rawRecord := range rawRecords {
+ record, decodeErr := decodeTypedRecord(ctx, s.codec, rawRecord)
+ if decodeErr != nil {
+ return nil, decodeErr
+ }
+ records = append(records, record)
+ }
+ return records, nil
+}
+
+func normalizeTypedDraft[T any](draft Draft[T]) (Draft[T], error) {
+ normalized := draft
+ normalized.ID = strings.TrimSpace(draft.ID)
+ normalized.Scope = draft.Scope.Normalize()
+
+ if normalized.ID == "" {
+ return Draft[T]{}, fmt.Errorf("%w: draft.id is required", ErrValidation)
+ }
+ if err := normalized.Scope.Validate("draft.scope"); err != nil {
+ return Draft[T]{}, err
+ }
+ if normalized.ExpectedVersion < 0 {
+ return Draft[T]{}, fmt.Errorf(
+ "%w: draft.expected_version cannot be negative: %d",
+ ErrValidation,
+ normalized.ExpectedVersion,
+ )
+ }
+
+ return normalized, nil
+}
+
+func encodeValidatedSpec[T any](
+ ctx context.Context,
+ codec KindCodec[T],
+ scope ResourceScope,
+ spec T,
+) ([]byte, error) {
+ encoded, err := codec.Encode(spec)
+ if err != nil {
+ return nil, err
+ }
+
+ validated, err := codec.DecodeAndValidate(ctx, scope, encoded)
+ if err != nil {
+ return nil, err
+ }
+
+ canonical, err := codec.Encode(validated)
+ if err != nil {
+ return nil, err
+ }
+ return canonical, nil
+}
+
+func decodeTypedRecord[T any](ctx context.Context, codec KindCodec[T], rawRecord RawRecord) (Record[T], error) {
+ if rawRecord.Kind.Normalize() != codec.Kind() {
+ return Record[T]{}, fmt.Errorf(
+ "%w: record kind %q does not match codec kind %q",
+ ErrValidation,
+ rawRecord.Kind,
+ codec.Kind(),
+ )
+ }
+
+ spec, err := codec.DecodeAndValidate(ctx, rawRecord.Scope, rawRecord.SpecJSON)
+ if err != nil {
+ return Record[T]{}, fmt.Errorf("resources: decode record %q/%q: %w", rawRecord.Kind, rawRecord.ID, err)
+ }
+
+ return Record[T]{
+ Kind: rawRecord.Kind,
+ ID: rawRecord.ID,
+ Version: rawRecord.Version,
+ Scope: rawRecord.Scope,
+ Owner: rawRecord.Owner,
+ Source: rawRecord.Source,
+ Spec: spec,
+ CreatedAt: rawRecord.CreatedAt,
+ UpdatedAt: rawRecord.UpdatedAt,
+ }, nil
+}
+
+func decodeTypedRecords[T any](ctx context.Context, codec KindCodec[T], rawRecords []RawRecord) ([]Record[T], error) {
+ records := make([]Record[T], 0, len(rawRecords))
+ for _, rawRecord := range rawRecords {
+ record, err := decodeTypedRecord(ctx, codec, rawRecord)
+ if err != nil {
+ return nil, err
+ }
+ records = append(records, record)
+ }
+ return records, nil
+}
diff --git a/internal/resources/typed_integration_test.go b/internal/resources/typed_integration_test.go
new file mode 100644
index 000000000..dc5867216
--- /dev/null
+++ b/internal/resources/typed_integration_test.go
@@ -0,0 +1,139 @@
+//go:build integration
+
+package resources
+
+import (
+ "testing"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestTypedStoreIntegrationPersistLoadAndList(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ codec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ record, err := store.Put(ctx, testDaemonActor(), Draft[testTypedSpec]{
+ ID: "integration-tool",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-integration"},
+ Spec: testTypedSpec{Name: "integration"},
+ })
+ if err != nil {
+ t.Fatalf("Put() error = %v", err)
+ }
+ if got, want := record.Version, int64(1); got != want {
+ t.Fatalf("record.Version = %d, want %d", got, want)
+ }
+
+ loaded, err := store.Get(ctx, testDaemonActor(), record.ID)
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if got, want := loaded.Spec.Name, "integration"; got != want {
+ t.Fatalf("loaded.Spec.Name = %q, want %q", got, want)
+ }
+
+ records, err := store.List(ctx, testDaemonActor(), ResourceFilter{
+ Scope: &ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-integration"},
+ })
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ if got, want := len(records), 1; got != want {
+ t.Fatalf("len(List()) = %d, want %d", got, want)
+ }
+
+ rawRecords, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Kind: testResourceKind})
+ if err != nil {
+ t.Fatalf("ListRaw() error = %v", err)
+ }
+ if got, want := len(rawRecords), 1; got != want {
+ t.Fatalf("len(ListRaw()) = %d, want %d", got, want)
+ }
+}
+
+func TestBundleActivationProjectorRegistrationIntegration(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ registry := NewCodecRegistry()
+
+ activationCodec := mustJSONCodec(t, bundleActivationKind, 1024, validateTestTypedSpec)
+ bundleCodec := mustJSONCodec(t, bundleKind, 1024, validateOtherTypedSpec)
+ if err := RegisterCodec(registry, activationCodec); err != nil {
+ t.Fatalf("RegisterCodec(activation) error = %v", err)
+ }
+ if err := RegisterCodec(registry, bundleCodec); err != nil {
+ t.Fatalf("RegisterCodec(bundle) error = %v", err)
+ }
+
+ activationStore, err := NewStore(kernel, activationCodec)
+ if err != nil {
+ t.Fatalf("NewStore(activation) error = %v", err)
+ }
+ bundleStore, err := NewStore(kernel, bundleCodec)
+ if err != nil {
+ t.Fatalf("NewStore(bundle) error = %v", err)
+ }
+
+ if _, err := bundleStore.Put(ctx, testDaemonActor(), Draft[otherTypedSpec]{
+ ID: "bundle-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ Spec: otherTypedSpec{Value: "bundle-1"},
+ }); err != nil {
+ t.Fatalf("bundleStore.Put() error = %v", err)
+ }
+ if _, err := activationStore.Put(ctx, testDaemonActor(), Draft[testTypedSpec]{
+ ID: "activation-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ Spec: testTypedSpec{Name: "activation-1"},
+ }); err != nil {
+ t.Fatalf("activationStore.Put() error = %v", err)
+ }
+
+ activationRaw, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Kind: bundleActivationKind})
+ if err != nil {
+ t.Fatalf("ListRaw(bundle.activation) error = %v", err)
+ }
+ bundleRaw, err := kernel.ListRaw(ctx, testDaemonActor(), ResourceFilter{Kind: bundleKind})
+ if err != nil {
+ t.Fatalf("ListRaw(bundle) error = %v", err)
+ }
+
+ domainProjector := &captureBundleActivationProjector{}
+ registration, err := NewBundleActivationProjectorRegistration(registry, domainProjector)
+ if err != nil {
+ t.Fatalf("NewBundleActivationProjectorRegistration() error = %v", err)
+ }
+ internalProjector, err := unwrapProjectorRegistration(registration)
+ if err != nil {
+ t.Fatalf("unwrapProjectorRegistration() error = %v", err)
+ }
+
+ plan, err := internalProjector.Build(ctx, projectionInput{
+ kind: bundleActivationKind,
+ records: activationRaw,
+ dependencies: map[ResourceKind][]RawRecord{
+ bundleKind: bundleRaw,
+ },
+ })
+ if err != nil {
+ t.Fatalf("Build() error = %v", err)
+ }
+ if got, want := len(domainProjector.activations), 1; got != want {
+ t.Fatalf("len(activations) = %d, want %d", got, want)
+ }
+ if got, want := len(domainProjector.bundles), 1; got != want {
+ t.Fatalf("len(bundles) = %d, want %d", got, want)
+ }
+ if got, want := plan.OperationCount(), 2; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+}
diff --git a/internal/resources/typed_test.go b/internal/resources/typed_test.go
new file mode 100644
index 000000000..2403daad1
--- /dev/null
+++ b/internal/resources/typed_test.go
@@ -0,0 +1,593 @@
+package resources
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+type testTypedSpec struct {
+ Name string `json:"name"`
+}
+
+type otherTypedSpec struct {
+ Value string `json:"value"`
+}
+
+type testPlan struct {
+ kind ResourceKind
+ revision int64
+ operations int
+}
+
+func (p testPlan) Kind() ResourceKind {
+ return p.kind
+}
+
+func (p testPlan) Revision() int64 {
+ return p.revision
+}
+
+func (p testPlan) OperationCount() int {
+ return p.operations
+}
+
+type countingCodec[T any] struct {
+ inner KindCodec[T]
+ decodeCount int
+ encodeCount int
+}
+
+func (c *countingCodec[T]) Kind() ResourceKind {
+ return c.inner.Kind()
+}
+
+func (c *countingCodec[T]) DecodeAndValidate(ctx context.Context, scope ResourceScope, raw []byte) (T, error) {
+ c.decodeCount++
+ return c.inner.DecodeAndValidate(ctx, scope, raw)
+}
+
+func (c *countingCodec[T]) Encode(spec T) ([]byte, error) {
+ c.encodeCount++
+ return c.inner.Encode(spec)
+}
+
+func (c *countingCodec[T]) MaxBytes() int {
+ return c.inner.MaxBytes()
+}
+
+type captureTypedProjector struct {
+ kind ResourceKind
+ dependsOn []ResourceKind
+ buildCalls int
+ records []Record[testTypedSpec]
+ applied ProjectionPlan
+}
+
+func (p *captureTypedProjector) Kind() ResourceKind {
+ return p.kind
+}
+
+func (p *captureTypedProjector) DependsOn() []ResourceKind {
+ return append([]ResourceKind(nil), p.dependsOn...)
+}
+
+func (p *captureTypedProjector) Build(
+ _ context.Context,
+ records []Record[testTypedSpec],
+) (ProjectionPlan, error) {
+ p.buildCalls++
+ p.records = append([]Record[testTypedSpec](nil), records...)
+ return testPlan{
+ kind: p.kind,
+ revision: 7,
+ operations: len(records),
+ }, nil
+}
+
+func (p *captureTypedProjector) Apply(_ context.Context, plan ProjectionPlan) error {
+ p.applied = plan
+ return nil
+}
+
+type captureBundleActivationProjector struct {
+ buildCalls int
+ activations []Record[testTypedSpec]
+ bundles []Record[otherTypedSpec]
+ applied ProjectionPlan
+}
+
+func (p *captureBundleActivationProjector) Build(
+ _ context.Context,
+ activations []Record[testTypedSpec],
+ bundles []Record[otherTypedSpec],
+) (ProjectionPlan, error) {
+ p.buildCalls++
+ p.activations = append([]Record[testTypedSpec](nil), activations...)
+ p.bundles = append([]Record[otherTypedSpec](nil), bundles...)
+ return testPlan{
+ kind: bundleActivationKind,
+ revision: 11,
+ operations: len(activations) + len(bundles),
+ }, nil
+}
+
+func (p *captureBundleActivationProjector) Apply(_ context.Context, plan ProjectionPlan) error {
+ p.applied = plan
+ return nil
+}
+
+func TestCodecRegistryRegistrationAndResolve(t *testing.T) {
+ t.Parallel()
+
+ registry := NewCodecRegistry()
+ codec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+
+ if err := RegisterCodec(registry, codec); err != nil {
+ t.Fatalf("RegisterCodec() error = %v", err)
+ }
+ if err := RegisterCodec(registry, codec); !errors.Is(err, ErrConflict) {
+ t.Fatalf("RegisterCodec(duplicate) error = %v, want ErrConflict", err)
+ }
+
+ resolved, err := ResolveCodec[testTypedSpec](registry, testResourceKind)
+ if err != nil {
+ t.Fatalf("ResolveCodec(testTypedSpec) error = %v", err)
+ }
+ if got, want := resolved.Kind(), testResourceKind; got != want {
+ t.Fatalf("resolved.Kind() = %q, want %q", got, want)
+ }
+
+ if _, err := ResolveCodec[otherTypedSpec](registry, testResourceKind); !errors.Is(err, ErrCodecTypeMismatch) {
+ t.Fatalf("ResolveCodec(type mismatch) error = %v, want ErrCodecTypeMismatch", err)
+ }
+ if _, err := ResolveCodec[testTypedSpec](registry, ResourceKind("missing")); !errors.Is(err, ErrCodecNotFound) {
+ t.Fatalf("ResolveCodec(missing) error = %v, want ErrCodecNotFound", err)
+ }
+}
+
+func TestTypedStoreReadAuthorityBoundaries(t *testing.T) {
+ t.Parallel()
+
+ codec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+
+ t.Run("foreign source get denied and list filtered", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ ctx := testutil.Context(t)
+ sourceAlpha := ResourceSource{Kind: ResourceSourceKind("extension"), ID: "ext-alpha"}
+ if err := kernel.ActivateSourceSession(ctx, testDaemonActor(), sourceAlpha, "nonce-alpha"); err != nil {
+ t.Fatalf("ActivateSourceSession() error = %v", err)
+ }
+ if err := kernel.ApplySourceSnapshotRaw(
+ ctx,
+ testExtensionActor("session-alpha", sourceAlpha.ID, "nonce-alpha"),
+ SourceSnapshot{
+ SourceVersion: 1,
+ Records: []RawDraft{{
+ Kind: testResourceKind,
+ ID: "foreign-tool",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ }},
+ },
+ ); err != nil {
+ t.Fatalf("ApplySourceSnapshotRaw() error = %v", err)
+ }
+
+ foreignActor := testExtensionActor("session-bravo", "ext-bravo", "nonce-bravo")
+ if _, err := store.Get(ctx, foreignActor, "foreign-tool"); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("Get(foreign source) error = %v, want ErrPermissionDenied", err)
+ }
+
+ records, err := store.List(ctx, foreignActor, ResourceFilter{})
+ if err != nil {
+ t.Fatalf("List(foreign source) error = %v", err)
+ }
+ if len(records) != 0 {
+ t.Fatalf("List(foreign source) = %#v, want none", records)
+ }
+ })
+
+ t.Run("granted kind mismatch rejected", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ ctx := testutil.Context(t)
+ record, err := store.Put(ctx, testDaemonActor(), Draft[testTypedSpec]{
+ ID: "kind-check",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ Spec: testTypedSpec{Name: "alpha"},
+ })
+ if err != nil {
+ t.Fatalf("Put() error = %v", err)
+ }
+
+ actor := testExtensionActor("session-kind", "ext-kind", "nonce-kind")
+ actor.GrantedKinds = []ResourceKind{ResourceKind("other.kind")}
+ actor.GrantedScopes = []ResourceScopeKind{ResourceScopeKindGlobal}
+
+ if _, err := store.Get(ctx, actor, record.ID); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("Get(denied kind) error = %v, want ErrPermissionDenied", err)
+ }
+ if _, err := store.List(ctx, actor, ResourceFilter{}); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("List(denied kind) error = %v, want ErrPermissionDenied", err)
+ }
+ })
+
+ t.Run("scope boundary rejected", func(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ ctx := testutil.Context(t)
+ record, err := store.Put(ctx, testDaemonActor(), Draft[testTypedSpec]{
+ ID: "scope-check",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ Spec: testTypedSpec{Name: "alpha"},
+ })
+ if err != nil {
+ t.Fatalf("Put() error = %v", err)
+ }
+
+ workspaceActor := testOperatorActor()
+ workspaceActor.MaxScope = ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"}
+ workspaceActor.GrantedKinds = []ResourceKind{testResourceKind}
+ workspaceActor.GrantedScopes = []ResourceScopeKind{ResourceScopeKindWorkspace}
+
+ if _, err := store.Get(ctx, workspaceActor, record.ID); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("Get(scope boundary) error = %v, want ErrPermissionDenied", err)
+ }
+ if _, err := store.List(ctx, workspaceActor, ResourceFilter{
+ Scope: &ResourceScope{Kind: ResourceScopeKindGlobal},
+ }); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("List(scope boundary) error = %v, want ErrPermissionDenied", err)
+ }
+ })
+}
+
+func TestTypedStoreDecodeFailureRejectsInvalidRawPayloads(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ codec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ if _, err := kernel.PutRaw(ctx, testDaemonActor(), RawDraft{
+ Kind: testResourceKind,
+ ID: "decode-failure",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ ExpectedVersion: 0,
+ SpecJSON: []byte(`{"name":" "}`),
+ }); err != nil {
+ t.Fatalf("PutRaw() error = %v", err)
+ }
+
+ if _, err := store.Get(ctx, testDaemonActor(), "decode-failure"); !errors.Is(err, ErrValidation) {
+ t.Fatalf("Get() error = %v, want ErrValidation", err)
+ }
+}
+
+func TestTypedStorePutRoundTripPreservesMetadata(t *testing.T) {
+ t.Parallel()
+
+ kernel, _ := openTestKernel(t)
+ ctx := testutil.Context(t)
+ codec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+ store, err := NewStore(kernel, codec)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ record, err := store.Put(ctx, testDaemonActor(), Draft[testTypedSpec]{
+ ID: "typed-round-trip",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-42"},
+ Spec: testTypedSpec{Name: " alpha "},
+ })
+ if err != nil {
+ t.Fatalf("Put() error = %v", err)
+ }
+
+ if got, want := record.Kind, testResourceKind; got != want {
+ t.Fatalf("record.Kind = %q, want %q", got, want)
+ }
+ if got, want := record.Version, int64(1); got != want {
+ t.Fatalf("record.Version = %d, want %d", got, want)
+ }
+ if got, want := record.Scope, (ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-42"}); got != want {
+ t.Fatalf("record.Scope = %#v, want %#v", got, want)
+ }
+ if got, want := record.Owner, (ResourceOwner{Kind: ResourceOwnerKind("daemon"), ID: "daemon-control"}); got != want {
+ t.Fatalf("record.Owner = %#v, want %#v", got, want)
+ }
+ if got, want := record.Source, (ResourceSource{Kind: ResourceSourceKind("daemon"), ID: "system"}); got != want {
+ t.Fatalf("record.Source = %#v, want %#v", got, want)
+ }
+ if got, want := record.Spec.Name, "alpha"; got != want {
+ t.Fatalf("record.Spec.Name = %q, want %q", got, want)
+ }
+
+ loaded, err := store.Get(ctx, testDaemonActor(), record.ID)
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if got, want := loaded.Version, record.Version; got != want {
+ t.Fatalf("loaded.Version = %d, want %d", got, want)
+ }
+ if got, want := loaded.Owner, record.Owner; got != want {
+ t.Fatalf("loaded.Owner = %#v, want %#v", got, want)
+ }
+ if got, want := loaded.Source, record.Source; got != want {
+ t.Fatalf("loaded.Source = %#v, want %#v", got, want)
+ }
+}
+
+func TestTypedProjectorRegistrationDecodesPrimaryKindOnce(t *testing.T) {
+ t.Parallel()
+
+ baseCodec := mustJSONCodec(t, testResourceKind, 1024, validateTestTypedSpec)
+ codec := &countingCodec[testTypedSpec]{inner: baseCodec}
+ domainProjector := &captureTypedProjector{
+ kind: testResourceKind,
+ }
+
+ registration, err := NewTypedProjectorRegistration(codec, domainProjector)
+ if err != nil {
+ t.Fatalf("NewTypedProjectorRegistration() error = %v", err)
+ }
+
+ internalProjector, err := unwrapProjectorRegistration(registration)
+ if err != nil {
+ t.Fatalf("unwrapProjectorRegistration() error = %v", err)
+ }
+
+ plan, err := internalProjector.Build(testutil.Context(t), projectionInput{
+ kind: testResourceKind,
+ records: []RawRecord{
+ {
+ Kind: testResourceKind,
+ ID: "alpha",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"name":"alpha"}`),
+ },
+ {
+ Kind: testResourceKind,
+ ID: "beta",
+ Scope: ResourceScope{Kind: ResourceScopeKindWorkspace, ID: "ws-1"},
+ SpecJSON: []byte(`{"name":"beta"}`),
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("Build() error = %v", err)
+ }
+ if got, want := codec.decodeCount, 2; got != want {
+ t.Fatalf("codec.decodeCount = %d, want %d", got, want)
+ }
+ if got, want := domainProjector.buildCalls, 1; got != want {
+ t.Fatalf("buildCalls = %d, want %d", got, want)
+ }
+ if got, want := len(domainProjector.records), 2; got != want {
+ t.Fatalf("len(records) = %d, want %d", got, want)
+ }
+ if got, want := plan.OperationCount(), 2; got != want {
+ t.Fatalf("plan.OperationCount() = %d, want %d", got, want)
+ }
+
+ if err := internalProjector.Apply(testutil.Context(t), plan); err != nil {
+ t.Fatalf("Apply() error = %v", err)
+ }
+ if domainProjector.applied == nil {
+ t.Fatal("Apply() did not reach domain projector")
+ }
+}
+
+func TestBundleActivationProjectorRegistrationDecodesDependenciesExplicitly(t *testing.T) {
+ t.Parallel()
+
+ registry := NewCodecRegistry()
+ activationCodec := &countingCodec[testTypedSpec]{
+ inner: mustJSONCodec(t, bundleActivationKind, 1024, validateTestTypedSpec),
+ }
+ bundleCodec := &countingCodec[otherTypedSpec]{
+ inner: mustJSONCodec(t, bundleKind, 1024, validateOtherTypedSpec),
+ }
+ if err := RegisterCodec(registry, activationCodec); err != nil {
+ t.Fatalf("RegisterCodec(activation) error = %v", err)
+ }
+ if err := RegisterCodec(registry, bundleCodec); err != nil {
+ t.Fatalf("RegisterCodec(bundle) error = %v", err)
+ }
+
+ domainProjector := &captureBundleActivationProjector{}
+ registration, err := NewBundleActivationProjectorRegistration(registry, domainProjector)
+ if err != nil {
+ t.Fatalf("NewBundleActivationProjectorRegistration() error = %v", err)
+ }
+ internalProjector, err := unwrapProjectorRegistration(registration)
+ if err != nil {
+ t.Fatalf("unwrapProjectorRegistration() error = %v", err)
+ }
+
+ plan, err := internalProjector.Build(testutil.Context(t), projectionInput{
+ kind: bundleActivationKind,
+ records: []RawRecord{{
+ Kind: bundleActivationKind,
+ ID: "activation-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"name":"activation-1"}`),
+ }},
+ dependencies: map[ResourceKind][]RawRecord{
+ bundleKind: {{
+ Kind: bundleKind,
+ ID: "bundle-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"value":"bundle-1"}`),
+ }},
+ },
+ })
+ if err != nil {
+ t.Fatalf("Build() error = %v", err)
+ }
+ if got, want := activationCodec.decodeCount, 1; got != want {
+ t.Fatalf("activationCodec.decodeCount = %d, want %d", got, want)
+ }
+ if got, want := bundleCodec.decodeCount, 1; got != want {
+ t.Fatalf("bundleCodec.decodeCount = %d, want %d", got, want)
+ }
+ if got, want := len(domainProjector.activations), 1; got != want {
+ t.Fatalf("len(activations) = %d, want %d", got, want)
+ }
+ if got, want := len(domainProjector.bundles), 1; got != want {
+ t.Fatalf("len(bundles) = %d, want %d", got, want)
+ }
+ if got, want := plan.Kind(), bundleActivationKind; got != want {
+ t.Fatalf("plan.Kind() = %q, want %q", got, want)
+ }
+
+ if err := internalProjector.Apply(testutil.Context(t), plan); err != nil {
+ t.Fatalf("Apply() error = %v", err)
+ }
+ if domainProjector.applied == nil {
+ t.Fatal("Apply() did not reach bundle activation projector")
+ }
+
+ _, err = internalProjector.Build(testutil.Context(t), projectionInput{
+ kind: bundleActivationKind,
+ dependencies: map[ResourceKind][]RawRecord{
+ ResourceKind("automation.job"): {{
+ Kind: ResourceKind("automation.job"),
+ ID: "job-1",
+ Scope: ResourceScope{Kind: ResourceScopeKindGlobal},
+ SpecJSON: []byte(`{"name":"job-1"}`),
+ }},
+ },
+ })
+ if !errors.Is(err, ErrValidation) {
+ t.Fatalf("Build(unexpected dependency kind) error = %v, want ErrValidation", err)
+ }
+}
+
+func TestTypedContractsDoNotExposeJSONRawMessage(t *testing.T) {
+ t.Parallel()
+
+ fileSet := token.NewFileSet()
+ targets := map[string]bool{
+ "SpecValidator": false,
+ "TypedProjector": false,
+ "BundleActivationProjector": false,
+ }
+ found := make(map[string]bool, len(targets))
+
+ for _, name := range []string{"codec.go", "projector.go"} {
+ path := filepath.Join(".", name)
+ file, err := parser.ParseFile(fileSet, path, nil, 0)
+ if err != nil {
+ t.Fatalf("ParseFile(%s) error = %v", path, err)
+ }
+ for _, decl := range file.Decls {
+ gen, ok := decl.(*ast.GenDecl)
+ if !ok {
+ continue
+ }
+ for _, spec := range gen.Specs {
+ typeSpec, ok := spec.(*ast.TypeSpec)
+ if !ok {
+ continue
+ }
+ if _, ok := targets[typeSpec.Name.Name]; !ok {
+ continue
+ }
+ found[typeSpec.Name.Name] = true
+ if containsJSONRawMessage(typeSpec.Type) {
+ t.Fatalf("%s must not expose json.RawMessage directly", typeSpec.Name.Name)
+ }
+ }
+ }
+ }
+
+ for name := range targets {
+ if !found[name] {
+ t.Fatalf("type %s not found in package AST", name)
+ }
+ }
+}
+
+func containsJSONRawMessage(node ast.Node) bool {
+ found := false
+ ast.Inspect(node, func(next ast.Node) bool {
+ if found {
+ return false
+ }
+ selector, ok := next.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ pkgName, ok := selector.X.(*ast.Ident)
+ if ok && pkgName.Name == "json" && selector.Sel.Name == "RawMessage" {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+func mustJSONCodec[T any](
+ t testing.TB,
+ kind ResourceKind,
+ maxBytes int,
+ validator SpecValidator[T],
+) KindCodec[T] {
+ t.Helper()
+
+ codec, err := NewJSONCodec(kind, maxBytes, validator)
+ if err != nil {
+ t.Fatalf("NewJSONCodec() error = %v", err)
+ }
+ return codec
+}
+
+func validateTestTypedSpec(_ context.Context, _ ResourceScope, spec testTypedSpec) (testTypedSpec, error) {
+ spec.Name = strings.TrimSpace(spec.Name)
+ if spec.Name == "" {
+ return testTypedSpec{}, fmt.Errorf("%w: test spec name is required", ErrValidation)
+ }
+ return spec, nil
+}
+
+func validateOtherTypedSpec(_ context.Context, _ ResourceScope, spec otherTypedSpec) (otherTypedSpec, error) {
+ spec.Value = strings.TrimSpace(spec.Value)
+ if spec.Value == "" {
+ return otherTypedSpec{}, fmt.Errorf("%w: other spec value is required", ErrValidation)
+ }
+ return spec, nil
+}
diff --git a/internal/resources/types.go b/internal/resources/types.go
new file mode 100644
index 000000000..9018c7042
--- /dev/null
+++ b/internal/resources/types.go
@@ -0,0 +1,269 @@
+package resources
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// ResourceKind identifies one canonical desired-state resource family.
+type ResourceKind string
+
+// Normalize returns the canonical trimmed resource kind.
+func (k ResourceKind) Normalize() ResourceKind {
+ return ResourceKind(strings.TrimSpace(string(k)))
+}
+
+// Validate reports whether the resource kind is present.
+func (k ResourceKind) Validate(path string) error {
+ if strings.TrimSpace(string(k)) == "" {
+ return fmt.Errorf("%w: %s is required", ErrValidation, path)
+ }
+ return nil
+}
+
+// MutationActorKind identifies the authenticated caller class.
+type MutationActorKind string
+
+const (
+ // MutationActorKindOperator identifies an operator-authorized control-plane caller.
+ MutationActorKindOperator MutationActorKind = "operator"
+ // MutationActorKindDaemon identifies a daemon-internal caller.
+ MutationActorKindDaemon MutationActorKind = "daemon"
+ // MutationActorKindExtension identifies an extension session caller.
+ MutationActorKindExtension MutationActorKind = "extension"
+)
+
+// Normalize returns the canonical trimmed actor kind.
+func (k MutationActorKind) Normalize() MutationActorKind {
+ return MutationActorKind(strings.TrimSpace(string(k)))
+}
+
+// Validate reports whether the actor kind is supported.
+func (k MutationActorKind) Validate(path string) error {
+ switch k.Normalize() {
+ case MutationActorKindOperator, MutationActorKindDaemon, MutationActorKindExtension:
+ return nil
+ default:
+ return fmt.Errorf(
+ "%w: %s must be %q, %q, or %q: %q",
+ ErrValidation,
+ path,
+ MutationActorKindOperator,
+ MutationActorKindDaemon,
+ MutationActorKindExtension,
+ k,
+ )
+ }
+}
+
+// ResourceScopeKind identifies the desired-state visibility scope.
+type ResourceScopeKind string
+
+const (
+ // ResourceScopeKindGlobal identifies a global-scope record.
+ ResourceScopeKindGlobal ResourceScopeKind = "global"
+ // ResourceScopeKindWorkspace identifies a workspace-scope record.
+ ResourceScopeKindWorkspace ResourceScopeKind = "workspace"
+)
+
+// Normalize returns the canonical trimmed scope kind.
+func (k ResourceScopeKind) Normalize() ResourceScopeKind {
+ return ResourceScopeKind(strings.TrimSpace(string(k)))
+}
+
+// Validate reports whether the scope kind is supported.
+func (k ResourceScopeKind) Validate(path string) error {
+ switch k.Normalize() {
+ case ResourceScopeKindGlobal, ResourceScopeKindWorkspace:
+ return nil
+ default:
+ return fmt.Errorf(
+ "%w: %s must be %q or %q: %q",
+ ErrValidation,
+ path,
+ ResourceScopeKindGlobal,
+ ResourceScopeKindWorkspace,
+ k,
+ )
+ }
+}
+
+// ResourceScope describes the persistence scope for one record.
+type ResourceScope struct {
+ Kind ResourceScopeKind `json:"kind"`
+ ID string `json:"id,omitempty"`
+}
+
+// Normalize returns a trimmed scope value.
+func (s ResourceScope) Normalize() ResourceScope {
+ return ResourceScope{
+ Kind: s.Kind.Normalize(),
+ ID: strings.TrimSpace(s.ID),
+ }
+}
+
+// Validate reports whether the scope binding is internally consistent.
+func (s ResourceScope) Validate(path string) error {
+ scopePath := nestedPath(path, "kind")
+ if err := s.Kind.Validate(scopePath); err != nil {
+ return err
+ }
+
+ idPath := nestedPath(path, "id")
+ switch s.Kind.Normalize() {
+ case ResourceScopeKindGlobal:
+ if strings.TrimSpace(s.ID) != "" {
+ return fmt.Errorf(
+ "%w: %s must be empty when %s is %q",
+ ErrInvalidScopeBinding,
+ idPath,
+ scopePath,
+ ResourceScopeKindGlobal,
+ )
+ }
+ case ResourceScopeKindWorkspace:
+ if strings.TrimSpace(s.ID) == "" {
+ return fmt.Errorf(
+ "%w: %s is required when %s is %q",
+ ErrInvalidScopeBinding,
+ idPath,
+ scopePath,
+ ResourceScopeKindWorkspace,
+ )
+ }
+ }
+
+ return nil
+}
+
+// ResourceSourceKind identifies the stamped source family for a record.
+type ResourceSourceKind string
+
+// Normalize returns the canonical trimmed source kind.
+func (k ResourceSourceKind) Normalize() ResourceSourceKind {
+ return ResourceSourceKind(strings.TrimSpace(string(k)))
+}
+
+// ResourceSource identifies the stamped canonical source for a record.
+type ResourceSource struct {
+ Kind ResourceSourceKind `json:"kind"`
+ ID string `json:"id"`
+}
+
+// Normalize returns a trimmed source value.
+func (s ResourceSource) Normalize() ResourceSource {
+ return ResourceSource{
+ Kind: s.Kind.Normalize(),
+ ID: strings.TrimSpace(s.ID),
+ }
+}
+
+// Validate reports whether the source is present.
+func (s ResourceSource) Validate(path string) error {
+ if strings.TrimSpace(string(s.Kind)) == "" {
+ return fmt.Errorf("%w: %s.kind is required", ErrValidation, path)
+ }
+ if strings.TrimSpace(s.ID) == "" {
+ return fmt.Errorf("%w: %s.id is required", ErrValidation, path)
+ }
+ return nil
+}
+
+// ResourceOwnerKind identifies the stamped ownership family for a record.
+type ResourceOwnerKind string
+
+// Normalize returns the canonical trimmed owner kind.
+func (k ResourceOwnerKind) Normalize() ResourceOwnerKind {
+ return ResourceOwnerKind(strings.TrimSpace(string(k)))
+}
+
+// ResourceOwner identifies the stamped owner for a record.
+type ResourceOwner struct {
+ Kind ResourceOwnerKind `json:"kind"`
+ ID string `json:"id"`
+}
+
+// Normalize returns a trimmed owner value.
+func (o ResourceOwner) Normalize() ResourceOwner {
+ return ResourceOwner{
+ Kind: o.Kind.Normalize(),
+ ID: strings.TrimSpace(o.ID),
+ }
+}
+
+// Validate reports whether the owner is present.
+func (o ResourceOwner) Validate(path string) error {
+ if strings.TrimSpace(string(o.Kind)) == "" {
+ return fmt.Errorf("%w: %s.kind is required", ErrValidation, path)
+ }
+ if strings.TrimSpace(o.ID) == "" {
+ return fmt.Errorf("%w: %s.id is required", ErrValidation, path)
+ }
+ return nil
+}
+
+// MutationActor describes the authoritative caller boundary for one mutation or read.
+type MutationActor struct {
+ Kind MutationActorKind `json:"kind"`
+ ID string `json:"id"`
+ SessionNonce string `json:"session_nonce,omitempty"`
+ Owner ResourceOwner `json:"owner"`
+ Source ResourceSource `json:"source"`
+ MaxScope ResourceScope `json:"max_scope"`
+ GrantedKinds []ResourceKind `json:"granted_kinds,omitempty"`
+ GrantedScopes []ResourceScopeKind `json:"granted_scopes,omitempty"`
+}
+
+// RawDraft carries one raw desired-state mutation at the persistence boundary.
+type RawDraft struct {
+ Kind ResourceKind
+ ID string
+ Scope ResourceScope
+ ExpectedVersion int64
+ SpecJSON []byte
+}
+
+// SourceSnapshot carries the full desired-state snapshot for one source session.
+type SourceSnapshot struct {
+ SourceVersion int64
+ Records []RawDraft
+}
+
+// RawRecord is the persisted raw desired-state shape.
+type RawRecord struct {
+ Kind ResourceKind
+ ID string
+ Version int64
+ Scope ResourceScope
+ Owner ResourceOwner
+ Source ResourceSource
+ SpecJSON []byte
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// ResourceFilter narrows list operations at the persistence boundary.
+type ResourceFilter struct {
+ Kind ResourceKind
+ Scope *ResourceScope
+ Owner *ResourceOwner
+ Source *ResourceSource
+ Limit int
+}
+
+// RawStore defines the raw CRUD plus snapshot boundary for desired-state persistence.
+type RawStore interface {
+ PutRaw(ctx context.Context, actor MutationActor, draft RawDraft) (RawRecord, error)
+ DeleteRaw(ctx context.Context, actor MutationActor, kind ResourceKind, id string, expectedVersion int64) error
+ ApplySourceSnapshotRaw(ctx context.Context, actor MutationActor, snapshot SourceSnapshot) error
+ GetRaw(ctx context.Context, actor MutationActor, kind ResourceKind, id string) (RawRecord, error)
+ ListRaw(ctx context.Context, actor MutationActor, filter ResourceFilter) ([]RawRecord, error)
+}
+
+// SourceSessionManager manages active source-session state for snapshot publication.
+type SourceSessionManager interface {
+ ActivateSourceSession(ctx context.Context, actor MutationActor, source ResourceSource, sessionNonce string) error
+ ResetSource(ctx context.Context, actor MutationActor, source ResourceSource) error
+}
diff --git a/internal/resources/validate.go b/internal/resources/validate.go
new file mode 100644
index 000000000..1f0475ce4
--- /dev/null
+++ b/internal/resources/validate.go
@@ -0,0 +1,255 @@
+package resources
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+)
+
+func normalizeActor(actor MutationActor) (MutationActor, error) {
+ normalized := actor
+ normalized.Kind = actor.Kind.Normalize()
+ normalized.ID = strings.TrimSpace(actor.ID)
+ normalized.SessionNonce = strings.TrimSpace(actor.SessionNonce)
+ normalized.Owner = actor.Owner.Normalize()
+ normalized.Source = actor.Source.Normalize()
+ normalized.MaxScope = actor.MaxScope.Normalize()
+ normalized.GrantedKinds = normalizeKinds(actor.GrantedKinds)
+ normalized.GrantedScopes = normalizeScopeKinds(actor.GrantedScopes)
+
+ if err := normalized.Kind.Validate("actor.kind"); err != nil {
+ return MutationActor{}, err
+ }
+ if normalized.ID == "" {
+ return MutationActor{}, fmt.Errorf("%w: actor.id is required", ErrValidation)
+ }
+ if err := normalized.MaxScope.Validate("actor.max_scope"); err != nil {
+ return MutationActor{}, err
+ }
+ if normalized.Kind == MutationActorKindExtension {
+ if err := normalized.Source.Validate("actor.source"); err != nil {
+ return MutationActor{}, err
+ }
+ }
+ if actorOwnerProvided(normalized.Owner) {
+ if normalized.Kind == MutationActorKindExtension {
+ return MutationActor{}, fmt.Errorf(
+ "%w: extension actors cannot override resource owner",
+ ErrPermissionDenied,
+ )
+ }
+ if err := normalized.Owner.Validate("actor.owner"); err != nil {
+ return MutationActor{}, err
+ }
+ }
+ return normalized, nil
+}
+
+func normalizeDraft(draft RawDraft, maxSpecBytes int) (RawDraft, error) {
+ normalized := draft
+ normalized.Kind = draft.Kind.Normalize()
+ normalized.ID = strings.TrimSpace(draft.ID)
+ normalized.Scope = draft.Scope.Normalize()
+
+ if err := normalized.Kind.Validate("draft.kind"); err != nil {
+ return RawDraft{}, err
+ }
+ if normalized.ID == "" {
+ return RawDraft{}, fmt.Errorf("%w: draft.id is required", ErrValidation)
+ }
+ if err := normalized.Scope.Validate("draft.scope"); err != nil {
+ return RawDraft{}, err
+ }
+ specJSON, err := normalizeJSON(draft.SpecJSON, maxSpecBytes, "draft.spec_json")
+ if err != nil {
+ return RawDraft{}, err
+ }
+ normalized.SpecJSON = specJSON
+ if normalized.ExpectedVersion < 0 {
+ return RawDraft{}, fmt.Errorf(
+ "%w: draft.expected_version cannot be negative: %d",
+ ErrValidation,
+ normalized.ExpectedVersion,
+ )
+ }
+ return normalized, nil
+}
+
+func normalizeSnapshot(snapshot SourceSnapshot, maxRecords int) (SourceSnapshot, error) {
+ normalized := snapshot
+ if normalized.SourceVersion <= 0 {
+ return SourceSnapshot{}, fmt.Errorf(
+ "%w: snapshot.source_version must be positive: %d",
+ ErrValidation,
+ normalized.SourceVersion,
+ )
+ }
+ if err := validateBoundedCount(len(snapshot.Records), maxRecords, "snapshot.records"); err != nil {
+ return SourceSnapshot{}, err
+ }
+ return normalized, nil
+}
+
+func normalizeJSON(payload []byte, maxBytes int, path string) ([]byte, error) {
+ trimmed := bytes.TrimSpace(payload)
+ if len(trimmed) == 0 {
+ return nil, fmt.Errorf("%w: %s is required", ErrValidation, path)
+ }
+ if !json.Valid(trimmed) {
+ return nil, fmt.Errorf("%w: %s must contain valid JSON", ErrValidation, path)
+ }
+ if len(trimmed) > maxBytes {
+ return nil, fmt.Errorf("%w: %s exceeds %d bytes", ErrPayloadTooLarge, path, maxBytes)
+ }
+ return append([]byte(nil), trimmed...), nil
+}
+
+func validateBoundedCount(count int, maxCount int, path string) error {
+ if count < 0 {
+ return fmt.Errorf("%w: %s cannot be negative: %d", ErrValidation, path, count)
+ }
+ if count > maxCount {
+ return fmt.Errorf("%w: %s exceeds %d: %d", ErrPayloadTooLarge, path, maxCount, count)
+ }
+ return nil
+}
+
+func validateActorWriteAccess(actor MutationActor, kind ResourceKind, scope ResourceScope) error {
+ if !actorAllowsKind(actor, kind) {
+ return fmt.Errorf("%w: actor cannot access resource kind %q", ErrPermissionDenied, kind)
+ }
+ if !actorAllowsScopeKind(actor, scope.Kind) {
+ return fmt.Errorf("%w: actor cannot access scope kind %q", ErrPermissionDenied, scope.Kind)
+ }
+ if !actorAllowsScope(actor, scope) {
+ return fmt.Errorf("%w: actor max scope does not allow %q/%q", ErrPermissionDenied, scope.Kind, scope.ID)
+ }
+ return nil
+}
+
+func validateActorReadAccess(actor MutationActor, record RawRecord) error {
+ if !actorAllowsKind(actor, record.Kind) {
+ return fmt.Errorf("%w: actor cannot read resource kind %q", ErrPermissionDenied, record.Kind)
+ }
+ if !actorAllowsScopeKind(actor, record.Scope.Kind) {
+ return fmt.Errorf("%w: actor cannot read scope kind %q", ErrPermissionDenied, record.Scope.Kind)
+ }
+ if !actorAllowsScope(actor, record.Scope) {
+ return fmt.Errorf(
+ "%w: actor max scope does not allow %q/%q",
+ ErrPermissionDenied,
+ record.Scope.Kind,
+ record.Scope.ID,
+ )
+ }
+ if actor.Kind == MutationActorKindExtension && record.Source != actor.Source {
+ return fmt.Errorf(
+ "%w: actor cannot read source %q/%q",
+ ErrPermissionDenied,
+ record.Source.Kind,
+ record.Source.ID,
+ )
+ }
+ return nil
+}
+
+func actorAllowsKind(actor MutationActor, kind ResourceKind) bool {
+ if len(actor.GrantedKinds) == 0 {
+ return actor.Kind != MutationActorKindExtension
+ }
+ return slices.Contains(actor.GrantedKinds, kind)
+}
+
+func actorAllowsScopeKind(actor MutationActor, scopeKind ResourceScopeKind) bool {
+ if len(actor.GrantedScopes) == 0 {
+ return actor.Kind != MutationActorKindExtension
+ }
+ return slices.Contains(actor.GrantedScopes, scopeKind)
+}
+
+func actorAllowsScope(actor MutationActor, target ResourceScope) bool {
+ maxScope := actor.MaxScope.Normalize()
+ target = target.Normalize()
+ switch maxScope.Kind {
+ case ResourceScopeKindGlobal:
+ return target.Kind == ResourceScopeKindGlobal || target.Kind == ResourceScopeKindWorkspace
+ case ResourceScopeKindWorkspace:
+ return target.Kind == ResourceScopeKindWorkspace && target.ID == maxScope.ID
+ default:
+ return false
+ }
+}
+
+func ownerFromActor(actor MutationActor) ResourceOwner {
+ if actorOwnerProvided(actor.Owner) {
+ return actor.Owner.Normalize()
+ }
+ if actor.Kind == MutationActorKindExtension {
+ return ResourceOwner{
+ Kind: ResourceOwnerKind(actor.Source.Kind),
+ ID: actor.Source.ID,
+ }
+ }
+ return ResourceOwner{
+ Kind: ResourceOwnerKind(actor.Kind),
+ ID: actor.ID,
+ }
+}
+
+func actorOwnerProvided(owner ResourceOwner) bool {
+ return strings.TrimSpace(string(owner.Kind)) != "" || strings.TrimSpace(owner.ID) != ""
+}
+
+func normalizeKinds(values []ResourceKind) []ResourceKind {
+ if len(values) == 0 {
+ return nil
+ }
+ normalized := make([]ResourceKind, 0, len(values))
+ seen := make(map[ResourceKind]struct{}, len(values))
+ for _, value := range values {
+ next := value.Normalize()
+ if next == "" {
+ continue
+ }
+ if _, ok := seen[next]; ok {
+ continue
+ }
+ seen[next] = struct{}{}
+ normalized = append(normalized, next)
+ }
+ return normalized
+}
+
+func normalizeScopeKinds(values []ResourceScopeKind) []ResourceScopeKind {
+ if len(values) == 0 {
+ return nil
+ }
+ normalized := make([]ResourceScopeKind, 0, len(values))
+ seen := make(map[ResourceScopeKind]struct{}, len(values))
+ for _, value := range values {
+ next := value.Normalize()
+ if next == "" {
+ continue
+ }
+ if _, ok := seen[next]; ok {
+ continue
+ }
+ seen[next] = struct{}{}
+ normalized = append(normalized, next)
+ }
+ return normalized
+}
+
+func nestedPath(path string, field string) string {
+ trimmedPath := strings.TrimSpace(path)
+ trimmedField := strings.TrimSpace(field)
+ if trimmedPath == "" {
+ return trimmedField
+ }
+ if trimmedField == "" {
+ return trimmedPath
+ }
+ return trimmedPath + "." + trimmedField
+}
diff --git a/internal/session/environment.go b/internal/session/environment.go
new file mode 100644
index 000000000..877e65d05
--- /dev/null
+++ b/internal/session/environment.go
@@ -0,0 +1,1129 @@
+package session
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/pedronauck/agh/internal/acp"
+ envpkg "github.com/pedronauck/agh/internal/environment"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/store"
+)
+
+const (
+ environmentStateCreating = "creating"
+ environmentStatePrepared = "prepared"
+ environmentStateStopped = "stopped"
+ environmentStateDestroyed = "destroyed"
+
+ environmentEventPrepareStart = "environment.prepare.start"
+ environmentEventPrepareComplete = "environment.prepare.complete"
+ environmentEventPrepareError = "environment.prepare.error"
+ environmentEventSyncStart = "environment.sync.start"
+ environmentEventSyncComplete = "environment.sync.complete"
+ environmentEventSyncError = "environment.sync.error"
+ environmentEventTransportConnect = "environment.transport.connect"
+ environmentEventTransportDisconnect = "environment.transport.disconnect"
+ environmentEventTransportError = "environment.transport.error"
+ environmentEventDestroyStart = "environment.destroy.start"
+ environmentEventDestroyComplete = "environment.destroy.complete"
+ environmentEventDestroyError = "environment.destroy.error"
+)
+
+// EnvironmentLifecycleEvent reports provider lifecycle timing to optional observers.
+type EnvironmentLifecycleEvent struct {
+ Name string
+ Span string
+ SessionID string
+ WorkspaceID string
+ EnvironmentID string
+ Backend string
+ Profile string
+ InstanceID string
+ Reason string
+ Duration time.Duration
+ ErrorKind string
+ Error string
+ Timestamp time.Time
+}
+
+// EnvironmentLifecycleNotifier is an optional notifier extension for environment lifecycle spans.
+type EnvironmentLifecycleNotifier interface {
+ OnEnvironmentLifecycleEvent(context.Context, EnvironmentLifecycleEvent)
+}
+
+func (m *Manager) prepareEnvironmentForStart(
+ ctx context.Context,
+ spec *sessionStartSpec,
+ session *Session,
+ opts acp.StartOpts,
+) (acp.StartOpts, error) {
+ if spec == nil {
+ return acp.StartOpts{}, errors.New("session: start spec is required")
+ }
+ if session == nil {
+ return acp.StartOpts{}, errors.New("session: session is required")
+ }
+ if m.environment == nil {
+ return acp.StartOpts{}, errors.New("session: environment registry is required")
+ }
+
+ resolvedEnv := normalizeResolvedEnvironment(spec.workspace.Environment)
+ provider, err := m.environment.Provider(resolvedEnv.Backend)
+ if err != nil {
+ return acp.StartOpts{}, fmt.Errorf("session: resolve environment provider %q: %w", resolvedEnv.Backend, err)
+ }
+
+ environmentID, meta, err := m.initializeEnvironmentMetaForStart(spec, session, resolvedEnv)
+ if err != nil {
+ return acp.StartOpts{}, err
+ }
+
+ req := envpkg.PrepareRequest{
+ SessionID: session.ID,
+ WorkspaceID: session.WorkspaceID,
+ EnvironmentID: environmentID,
+ InstanceID: meta.InstanceID,
+ LocalRootDir: spec.workspace.RootDir,
+ LocalAdditionalDirs: append([]string(nil), spec.workspace.AdditionalDirs...),
+ Environment: resolvedEnv,
+ AgentCommand: opts.Command,
+ AgentEnv: environmentAgentEnv(opts.Env, resolvedEnv),
+ Permissions: string(opts.Permissions),
+ ResumeACPState: opts.ResumeSessionID,
+ ProviderState: cloneRawMessage(meta.ProviderState),
+ }
+ req, err = m.dispatchEnvironmentPrepare(ctx, session, req)
+ if err != nil {
+ return acp.StartOpts{}, err
+ }
+
+ prepared, prepareErr := m.callEnvironmentPrepare(ctx, provider, req, meta)
+ if prepareErr != nil {
+ return acp.StartOpts{}, prepareErr
+ }
+
+ state, err := normalizePreparedEnvironmentState(prepared, meta, resolvedEnv)
+ if err != nil {
+ return acp.StartOpts{}, err
+ }
+ meta = sessionEnvironmentMetaFromState(state, environmentStatePrepared)
+ session.setEnvironment(meta, m.now())
+ if err := m.writeMeta(session); err != nil {
+ return acp.StartOpts{}, err
+ }
+
+ if err := m.syncEnvironmentToRuntime(ctx, provider, session, state, meta); err != nil {
+ return acp.StartOpts{}, err
+ }
+ meta = cloneSessionEnvironmentMeta(session.Info().Environment)
+ if err := m.dispatchEnvironmentReady(ctx, session, state, meta); err != nil {
+ return acp.StartOpts{}, err
+ }
+
+ return environmentStartOpts(opts, prepared, state), nil
+}
+
+func (m *Manager) initializeEnvironmentMetaForStart(
+ spec *sessionStartSpec,
+ session *Session,
+ resolvedEnv envpkg.Resolved,
+) (string, *store.SessionEnvironmentMeta, error) {
+ environmentID := strings.TrimSpace(spec.environmentID)
+ if environmentID == "" {
+ environmentID = sessionEnvironmentID(spec.environment)
+ }
+ if environmentID == "" {
+ environmentID = strings.TrimSpace(m.newEnvironmentID())
+ }
+ if environmentID == "" {
+ return "", nil, errors.New("session: environment id generator returned empty id")
+ }
+ spec.environmentID = environmentID
+
+ meta := initialSessionEnvironmentMeta(environmentID, resolvedEnv, spec.environment)
+ session.setEnvironment(meta, m.now())
+ if err := m.writeMeta(session); err != nil {
+ return "", nil, err
+ }
+ return environmentID, meta, nil
+}
+
+func (m *Manager) callEnvironmentPrepare(
+ ctx context.Context,
+ provider envpkg.Provider,
+ req envpkg.PrepareRequest,
+ meta *store.SessionEnvironmentMeta,
+) (envpkg.Prepared, error) {
+ started := time.Now()
+ event := environmentEventFromMeta(meta, req.SessionID, req.WorkspaceID, environmentEventPrepareStart, "")
+ m.logEnvironmentLifecycle(event)
+
+ prepared, err := provider.Prepare(ctx, req)
+ duration := time.Since(started)
+ if err != nil {
+ errorEvent := environmentEventFromMeta(meta, req.SessionID, req.WorkspaceID, environmentEventPrepareError, "")
+ errorEvent.Duration = duration
+ attachEnvironmentError(&errorEvent, err)
+ m.logEnvironmentLifecycle(errorEvent)
+ return envpkg.Prepared{}, fmt.Errorf(
+ "session: prepare environment %q for %q: %w",
+ req.EnvironmentID,
+ req.SessionID,
+ err,
+ )
+ }
+
+ completeMeta := sessionEnvironmentMetaFromState(prepared.State, environmentStatePrepared)
+ if completeMeta.EnvironmentID == "" {
+ completeMeta.EnvironmentID = req.EnvironmentID
+ }
+ if completeMeta.Backend == "" {
+ completeMeta.Backend = string(provider.Backend())
+ }
+ if completeMeta.Profile == "" {
+ completeMeta.Profile = req.Environment.Profile
+ }
+ completeEvent := environmentEventFromMeta(
+ completeMeta,
+ req.SessionID,
+ req.WorkspaceID,
+ environmentEventPrepareComplete,
+ "",
+ )
+ completeEvent.Duration = duration
+ m.logEnvironmentLifecycle(completeEvent)
+ return prepared, nil
+}
+
+func (m *Manager) dispatchEnvironmentPrepare(
+ ctx context.Context,
+ session *Session,
+ req envpkg.PrepareRequest,
+) (envpkg.PrepareRequest, error) {
+ payload := hookspkg.EnvironmentPreparePayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookEnvironmentPrepare,
+ Timestamp: m.now(),
+ },
+ SessionContext: hookSessionContext(session),
+ EnvironmentID: strings.TrimSpace(req.EnvironmentID),
+ Backend: string(req.Environment.Backend),
+ Profile: environmentProfilePayload(req.Environment),
+ LocalRootDir: strings.TrimSpace(req.LocalRootDir),
+ LocalAdditionalDirs: append([]string(nil), req.LocalAdditionalDirs...),
+ AgentCommand: strings.TrimSpace(req.AgentCommand),
+ AgentEnv: append([]string(nil), req.AgentEnv...),
+ Permissions: strings.TrimSpace(req.Permissions),
+ ResumeACPState: strings.TrimSpace(req.ResumeACPState),
+ }
+ patched, err := m.hooks.environment().DispatchEnvironmentPrepare(ctx, payload)
+ if err != nil {
+ return req, err
+ }
+ if patched.Denied {
+ if reason := strings.TrimSpace(patched.DenyReason); reason != "" {
+ return req, fmt.Errorf("session: environment prepare denied: %s", reason)
+ }
+ return req, errors.New("session: environment prepare denied")
+ }
+ if len(patched.EnvOverrides) == 0 {
+ return req, nil
+ }
+
+ req.Environment.Env = mergeEnvironmentEnv(req.Environment.Env, patched.EnvOverrides)
+ req.AgentEnv = applyEnvironmentEnvOverrides(req.AgentEnv, patched.EnvOverrides)
+ return req, nil
+}
+
+func (m *Manager) dispatchEnvironmentReady(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+) error {
+ payload := hookspkg.EnvironmentReadyPayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookEnvironmentReady,
+ Timestamp: m.now(),
+ },
+ SessionContext: hookSessionContext(session),
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ RuntimeAdditionalDirs: append([]string(nil), state.RuntimeAdditionalDirs...),
+ }
+ if meta != nil {
+ if payload.EnvironmentID == "" {
+ payload.EnvironmentID = strings.TrimSpace(meta.EnvironmentID)
+ }
+ if payload.Backend == "" {
+ payload.Backend = strings.TrimSpace(meta.Backend)
+ }
+ if payload.Profile == "" {
+ payload.Profile = strings.TrimSpace(meta.Profile)
+ }
+ if payload.InstanceID == "" {
+ payload.InstanceID = strings.TrimSpace(meta.InstanceID)
+ }
+ if payload.RuntimeRootDir == "" {
+ payload.RuntimeRootDir = strings.TrimSpace(meta.RuntimeRootDir)
+ }
+ }
+ _, err := m.hooks.environment().DispatchEnvironmentReady(ctx, payload)
+ return err
+}
+
+func (m *Manager) dispatchEnvironmentSyncBefore(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ direction envpkg.SyncDirection,
+ reason envpkg.SyncReason,
+) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ payload := hookspkg.EnvironmentSyncBeforePayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookEnvironmentSyncBefore,
+ Timestamp: m.now(),
+ },
+ SessionContext: hookSessionContext(session),
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ Direction: string(direction),
+ Reason: string(reason),
+ FileCount: m.environmentSyncFileCount(session, direction),
+ }
+ applyEnvironmentMetaFallbacks(&payload.EnvironmentID, &payload.Backend, &payload.Profile, &payload.InstanceID, meta)
+ return m.hooks.environment().DispatchEnvironmentSyncBefore(ctx, payload)
+}
+
+func (m *Manager) dispatchEnvironmentSyncAfter(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ direction envpkg.SyncDirection,
+ reason envpkg.SyncReason,
+ duration time.Duration,
+ result envpkg.SyncResult,
+ errorsList []string,
+) error {
+ payload := hookspkg.EnvironmentSyncAfterPayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookEnvironmentSyncAfter,
+ Timestamp: m.now(),
+ },
+ SessionContext: hookSessionContext(session),
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ Direction: string(direction),
+ Reason: string(reason),
+ FilesSynced: result.FilesSynced,
+ BytesTransferred: result.BytesTransferred,
+ DurationMS: duration.Milliseconds(),
+ Errors: append([]string(nil), errorsList...),
+ }
+ applyEnvironmentMetaFallbacks(&payload.EnvironmentID, &payload.Backend, &payload.Profile, &payload.InstanceID, meta)
+ _, err := m.hooks.environment().DispatchEnvironmentSyncAfter(ctx, payload)
+ return err
+}
+
+func (m *Manager) dispatchEnvironmentStop(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ reason envpkg.SyncReason,
+ willDestroy bool,
+) (hookspkg.EnvironmentStopPayload, error) {
+ stopReason := string(reason)
+ if info := session.Info(); info != nil && strings.TrimSpace(string(info.StopReason)) != "" {
+ stopReason = string(info.StopReason)
+ }
+ payload := hookspkg.EnvironmentStopPayload{
+ PayloadBase: hookspkg.PayloadBase{
+ Event: hookspkg.HookEnvironmentStop,
+ Timestamp: m.now(),
+ },
+ SessionContext: hookSessionContext(session),
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ StopReason: strings.TrimSpace(stopReason),
+ WillDestroy: willDestroy,
+ }
+ applyEnvironmentMetaFallbacks(&payload.EnvironmentID, &payload.Backend, &payload.Profile, &payload.InstanceID, meta)
+ return m.hooks.environment().DispatchEnvironmentStop(ctx, payload)
+}
+
+func (m *Manager) syncEnvironmentToRuntime(
+ ctx context.Context,
+ provider envpkg.Provider,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+) error {
+ return m.syncEnvironmentRuntime(
+ ctx,
+ session,
+ state,
+ meta,
+ envpkg.SyncDirectionToRuntime,
+ envpkg.SyncReasonStart,
+ provider.SyncToRuntime,
+ )
+}
+
+type environmentSyncRunner func(context.Context, envpkg.SessionState, envpkg.SyncOptions) (envpkg.SyncResult, error)
+
+func (m *Manager) syncEnvironmentRuntime(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ direction envpkg.SyncDirection,
+ reason envpkg.SyncReason,
+ run environmentSyncRunner,
+) error {
+ started := time.Now()
+ m.logEnvironmentLifecycle(environmentEventFromMeta(
+ meta,
+ session.ID,
+ session.WorkspaceID,
+ environmentEventSyncStart,
+ string(reason),
+ ))
+
+ before, err := m.dispatchEnvironmentSyncBefore(ctx, session, state, meta, direction, reason)
+ if err != nil {
+ return err
+ }
+ if before.Denied {
+ return nil
+ }
+
+ result, err := run(ctx, state, envpkg.SyncOptions{
+ Reason: reason,
+ ExcludePatterns: append([]string(nil), before.ExcludePatterns...),
+ })
+ duration := time.Since(started)
+ errorsList := syncResultErrors(result, err)
+ now := m.now()
+ meta = cloneSessionEnvironmentMeta(meta)
+ meta.LastSyncAt = &now
+ if err != nil {
+ return m.finishEnvironmentSyncError(ctx, session, state, meta, direction, reason, environmentSyncOutcome{
+ result: result,
+ duration: duration,
+ errorsList: errorsList,
+ syncTime: now,
+ err: err,
+ })
+ }
+ return m.finishEnvironmentSyncSuccess(ctx, session, state, meta, direction, reason, environmentSyncOutcome{
+ result: result,
+ duration: duration,
+ errorsList: errorsList,
+ syncTime: now,
+ })
+}
+
+type environmentSyncOutcome struct {
+ result envpkg.SyncResult
+ duration time.Duration
+ errorsList []string
+ syncTime time.Time
+ err error
+}
+
+func (m *Manager) finishEnvironmentSyncSuccess(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ direction envpkg.SyncDirection,
+ reason envpkg.SyncReason,
+ outcome environmentSyncOutcome,
+) error {
+ meta.LastSyncError = ""
+ session.setEnvironment(meta, outcome.syncTime)
+ if err := m.writeMeta(session); err != nil {
+ return err
+ }
+ if err := m.dispatchEnvironmentSyncAfter(
+ ctx,
+ session,
+ state,
+ meta,
+ direction,
+ reason,
+ outcome.duration,
+ outcome.result,
+ outcome.errorsList,
+ ); err != nil {
+ return err
+ }
+ completeEvent := environmentEventFromMeta(
+ meta,
+ session.ID,
+ session.WorkspaceID,
+ environmentEventSyncComplete,
+ string(reason),
+ )
+ completeEvent.Duration = outcome.duration
+ m.logEnvironmentLifecycle(completeEvent)
+ return nil
+}
+
+func (m *Manager) finishEnvironmentSyncError(
+ ctx context.Context,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ direction envpkg.SyncDirection,
+ reason envpkg.SyncReason,
+ outcome environmentSyncOutcome,
+) error {
+ err := syncEnvironmentWriteError(m, session, meta, outcome)
+ errorsList := syncResultErrors(outcome.result, err)
+ if afterErr := m.dispatchEnvironmentSyncAfter(
+ ctx,
+ session,
+ state,
+ meta,
+ direction,
+ reason,
+ outcome.duration,
+ outcome.result,
+ errorsList,
+ ); afterErr != nil {
+ m.warnHookDispatch(ctx, session, hookspkg.HookEnvironmentSyncAfter, afterErr)
+ }
+
+ errorEvent := environmentEventFromMeta(
+ meta,
+ session.ID,
+ session.WorkspaceID,
+ environmentEventSyncError,
+ string(reason),
+ )
+ errorEvent.Duration = outcome.duration
+ attachEnvironmentError(&errorEvent, err)
+ m.logEnvironmentLifecycle(errorEvent)
+ return fmt.Errorf(
+ "session: sync environment %q %s runtime for %q: %w",
+ state.EnvironmentID,
+ syncDirectionPreposition(direction),
+ session.ID,
+ err,
+ )
+}
+
+func syncEnvironmentWriteError(
+ m *Manager,
+ session *Session,
+ meta *store.SessionEnvironmentMeta,
+ outcome environmentSyncOutcome,
+) error {
+ meta.LastSyncError = outcome.err.Error()
+ session.setEnvironment(meta, outcome.syncTime)
+ if writeErr := m.writeMeta(session); writeErr != nil {
+ return errors.Join(outcome.err, writeErr)
+ }
+ return outcome.err
+}
+
+func syncDirectionPreposition(direction envpkg.SyncDirection) string {
+ if direction == envpkg.SyncDirectionFromRuntime {
+ return "from"
+ }
+ return "to"
+}
+
+func (m *Manager) finalizeEnvironment(
+ ctx context.Context,
+ session *Session,
+ reason envpkg.SyncReason,
+) error {
+ if session == nil {
+ return nil
+ }
+ meta := cloneSessionEnvironmentMeta(session.Info().Environment)
+ if meta == nil {
+ return nil
+ }
+ if m.environment == nil {
+ return errors.New("session: environment registry is required")
+ }
+
+ provider, err := m.environment.Provider(envpkg.Backend(strings.TrimSpace(meta.Backend)))
+ if err != nil {
+ return fmt.Errorf("session: resolve environment provider %q: %w", meta.Backend, err)
+ }
+
+ state := sessionEnvironmentStateFromMeta(meta)
+ var errs []error
+ if syncErr := m.syncEnvironmentFromRuntime(ctx, provider, session, state, meta, reason); syncErr != nil {
+ if reason == envpkg.SyncReasonCrash {
+ m.sessionLogger(session).Warn("session: environment crash sync failed", "error", syncErr)
+ } else {
+ errs = append(errs, syncErr)
+ }
+ meta = cloneSessionEnvironmentMeta(session.Info().Environment)
+ state = sessionEnvironmentStateFromMeta(meta)
+ }
+
+ shouldDestroy := session.environmentShouldDestroy()
+ stopPayload, stopErr := m.dispatchEnvironmentStop(ctx, session, state, meta, reason, shouldDestroy)
+ if stopErr != nil {
+ errs = append(errs, stopErr)
+ shouldDestroy = false
+ }
+ if stopPayload.Denied {
+ shouldDestroy = false
+ }
+
+ if shouldDestroy {
+ if destroyErr := m.destroyEnvironment(ctx, provider, session, state); destroyErr != nil {
+ errs = append(errs, destroyErr)
+ }
+ } else {
+ now := m.now()
+ meta = cloneSessionEnvironmentMeta(session.Info().Environment)
+ if meta != nil {
+ meta.State = environmentStateStopped
+ session.setEnvironment(meta, now)
+ if err := m.writeMeta(session); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ }
+
+ return errors.Join(errs...)
+}
+
+func (m *Manager) syncEnvironmentFromRuntime(
+ ctx context.Context,
+ provider envpkg.Provider,
+ session *Session,
+ state envpkg.SessionState,
+ meta *store.SessionEnvironmentMeta,
+ reason envpkg.SyncReason,
+) error {
+ return m.syncEnvironmentRuntime(
+ ctx,
+ session,
+ state,
+ meta,
+ envpkg.SyncDirectionFromRuntime,
+ reason,
+ provider.SyncFromRuntime,
+ )
+}
+
+func (m *Manager) destroyEnvironment(
+ ctx context.Context,
+ provider envpkg.Provider,
+ session *Session,
+ state envpkg.SessionState,
+) error {
+ meta := cloneSessionEnvironmentMeta(session.Info().Environment)
+ started := time.Now()
+ startEvent := environmentEventFromMeta(meta, session.ID, session.WorkspaceID, environmentEventDestroyStart, "")
+ m.logEnvironmentLifecycle(startEvent)
+
+ err := provider.Destroy(ctx, state)
+ duration := time.Since(started)
+ if err != nil {
+ errorEvent := environmentEventFromMeta(meta, session.ID, session.WorkspaceID, environmentEventDestroyError, "")
+ errorEvent.Duration = duration
+ attachEnvironmentError(&errorEvent, err)
+ m.logEnvironmentLifecycle(errorEvent)
+ return fmt.Errorf("session: destroy environment %q for %q: %w", state.EnvironmentID, session.ID, err)
+ }
+
+ now := m.now()
+ meta = cloneSessionEnvironmentMeta(meta)
+ if meta != nil {
+ meta.State = environmentStateDestroyed
+ session.setEnvironment(meta, now)
+ if err := m.writeMeta(session); err != nil {
+ return err
+ }
+ }
+ completeEvent := environmentEventFromMeta(
+ meta,
+ session.ID,
+ session.WorkspaceID,
+ environmentEventDestroyComplete,
+ "",
+ )
+ completeEvent.Duration = duration
+ m.logEnvironmentLifecycle(completeEvent)
+ return nil
+}
+
+func (m *Manager) logEnvironmentTransport(session *Session, eventName string, err error, duration time.Duration) {
+ if session == nil {
+ return
+ }
+ meta := cloneSessionEnvironmentMeta(session.Info().Environment)
+ event := environmentEventFromMeta(meta, session.ID, session.WorkspaceID, eventName, "")
+ event.Duration = duration
+ if err != nil {
+ attachEnvironmentError(&event, err)
+ }
+ m.logEnvironmentLifecycle(event)
+}
+
+func (m *Manager) logEnvironmentLifecycle(event EnvironmentLifecycleEvent) {
+ event.Name = strings.TrimSpace(event.Name)
+ if event.Name == "" {
+ return
+ }
+ if event.Timestamp.IsZero() {
+ event.Timestamp = m.now()
+ }
+ if event.Span == "" {
+ event.Span = environmentSpanForEvent(event.Name, event.Reason)
+ }
+ logger := m.logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ args := []any{
+ "backend", strings.TrimSpace(event.Backend),
+ "profile", strings.TrimSpace(event.Profile),
+ "environment_id", strings.TrimSpace(event.EnvironmentID),
+ "instance_id", strings.TrimSpace(event.InstanceID),
+ "workspace_id", strings.TrimSpace(event.WorkspaceID),
+ "session_id", strings.TrimSpace(event.SessionID),
+ "duration_ms", event.Duration.Milliseconds(),
+ }
+ if strings.TrimSpace(event.Reason) != "" {
+ args = append(args, "reason", strings.TrimSpace(event.Reason))
+ }
+ if strings.TrimSpace(event.ErrorKind) != "" {
+ args = append(args, "error_kind", strings.TrimSpace(event.ErrorKind))
+ }
+ if strings.TrimSpace(event.Error) != "" {
+ args = append(args, "error", strings.TrimSpace(event.Error))
+ }
+ if strings.Contains(event.Name, ".error") {
+ logger.Warn(event.Name, args...)
+ } else {
+ logger.Info(event.Name, args...)
+ }
+
+ if notifier, ok := m.notifier.(EnvironmentLifecycleNotifier); ok {
+ notifier.OnEnvironmentLifecycleEvent(m.lifecycleCtx, event)
+ }
+}
+
+func environmentStartOpts(
+ opts acp.StartOpts,
+ prepared envpkg.Prepared,
+ state envpkg.SessionState,
+) acp.StartOpts {
+ next := opts
+ if command := strings.TrimSpace(prepared.Launch.Command); command != "" {
+ next.Command = command
+ }
+ if prepared.Launch.Env != nil {
+ next.Env = append([]string(nil), prepared.Launch.Env...)
+ }
+ next.Cwd = strings.TrimSpace(prepared.RuntimeRootDir)
+ if next.Cwd == "" {
+ next.Cwd = strings.TrimSpace(state.RuntimeRootDir)
+ }
+ next.AdditionalDirs = append([]string(nil), prepared.RuntimeAdditionalDirs...)
+ if next.AdditionalDirs == nil {
+ next.AdditionalDirs = append([]string(nil), state.RuntimeAdditionalDirs...)
+ }
+ next.Launcher = prepared.Launcher
+ next.ToolHost = prepared.ToolHost
+ return next
+}
+
+func initialSessionEnvironmentMeta(
+ environmentID string,
+ resolved envpkg.Resolved,
+ previous *store.SessionEnvironmentMeta,
+) *store.SessionEnvironmentMeta {
+ meta := cloneSessionEnvironmentMeta(previous)
+ if meta == nil {
+ meta = &store.SessionEnvironmentMeta{}
+ }
+ meta.EnvironmentID = strings.TrimSpace(environmentID)
+ meta.Backend = string(resolved.Backend)
+ meta.Profile = strings.TrimSpace(resolved.Profile)
+ meta.State = environmentStateCreating
+ return meta
+}
+
+func normalizePreparedEnvironmentState(
+ prepared envpkg.Prepared,
+ meta *store.SessionEnvironmentMeta,
+ resolved envpkg.Resolved,
+) (envpkg.SessionState, error) {
+ state := prepared.State
+ if strings.TrimSpace(state.EnvironmentID) == "" {
+ state.EnvironmentID = strings.TrimSpace(meta.EnvironmentID)
+ }
+ if state.Backend == "" {
+ state.Backend = resolved.Backend
+ }
+ if strings.TrimSpace(state.Profile) == "" {
+ state.Profile = strings.TrimSpace(resolved.Profile)
+ }
+ if strings.TrimSpace(state.State) == "" {
+ state.State = environmentStatePrepared
+ }
+ if strings.TrimSpace(state.RuntimeRootDir) == "" {
+ state.RuntimeRootDir = strings.TrimSpace(prepared.RuntimeRootDir)
+ }
+ if strings.TrimSpace(state.RuntimeRootDir) == "" {
+ state.RuntimeRootDir = strings.TrimSpace(prepared.Launch.Cwd)
+ }
+ if len(state.RuntimeAdditionalDirs) == 0 {
+ state.RuntimeAdditionalDirs = append([]string(nil), prepared.RuntimeAdditionalDirs...)
+ }
+ if state.ProviderState == nil {
+ state.ProviderState = cloneRawMessage(meta.ProviderState)
+ }
+ if strings.TrimSpace(state.EnvironmentID) == "" {
+ return envpkg.SessionState{}, errors.New("session: prepared environment id is required")
+ }
+ if !state.Backend.Valid() {
+ return envpkg.SessionState{}, fmt.Errorf("session: prepared environment backend %q is invalid", state.Backend)
+ }
+ if strings.TrimSpace(state.RuntimeRootDir) == "" {
+ return envpkg.SessionState{}, errors.New("session: prepared runtime root dir is required")
+ }
+ return state, nil
+}
+
+func sessionEnvironmentMetaFromState(
+ state envpkg.SessionState,
+ fallbackState string,
+) *store.SessionEnvironmentMeta {
+ if strings.TrimSpace(state.EnvironmentID) == "" && state.Backend == "" {
+ return nil
+ }
+ sessionState := strings.TrimSpace(state.State)
+ if sessionState == "" {
+ sessionState = fallbackState
+ }
+ return &store.SessionEnvironmentMeta{
+ EnvironmentID: strings.TrimSpace(state.EnvironmentID),
+ Backend: string(state.Backend),
+ Profile: strings.TrimSpace(state.Profile),
+ State: sessionState,
+ InstanceID: strings.TrimSpace(state.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(state.RuntimeRootDir),
+ RuntimeAdditionalDirs: append([]string(nil), state.RuntimeAdditionalDirs...),
+ ProviderState: cloneRawMessage(state.ProviderState),
+ SSHAccessExpiresAt: cloneTimePointer(state.SSHAccessExpiresAt),
+ }
+}
+
+func sessionEnvironmentStateFromMeta(meta *store.SessionEnvironmentMeta) envpkg.SessionState {
+ if meta == nil {
+ return envpkg.SessionState{}
+ }
+ return envpkg.SessionState{
+ EnvironmentID: strings.TrimSpace(meta.EnvironmentID),
+ Backend: envpkg.Backend(strings.TrimSpace(meta.Backend)),
+ Profile: strings.TrimSpace(meta.Profile),
+ State: strings.TrimSpace(meta.State),
+ InstanceID: strings.TrimSpace(meta.InstanceID),
+ RuntimeRootDir: strings.TrimSpace(meta.RuntimeRootDir),
+ RuntimeAdditionalDirs: append([]string(nil), meta.RuntimeAdditionalDirs...),
+ ProviderState: cloneRawMessage(meta.ProviderState),
+ SSHAccessExpiresAt: cloneTimePointer(meta.SSHAccessExpiresAt),
+ }
+}
+
+func sessionEnvironmentID(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.EnvironmentID)
+}
+
+func normalizeResolvedEnvironment(resolved envpkg.Resolved) envpkg.Resolved {
+ if !resolved.Backend.Valid() {
+ resolved.Backend = envpkg.BackendLocal
+ }
+ if strings.TrimSpace(resolved.Profile) == "" {
+ resolved.Profile = string(resolved.Backend)
+ }
+ return resolved
+}
+
+func environmentAgentEnv(base []string, resolved envpkg.Resolved) []string {
+ env := append([]string(nil), base...)
+ if len(resolved.Env) == 0 {
+ return env
+ }
+ keys := make([]string, 0, len(resolved.Env))
+ for key := range resolved.Env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ env = setSessionStartEnvValue(env, key, resolved.Env[key])
+ }
+ return env
+}
+
+func environmentProfilePayload(resolved envpkg.Resolved) hookspkg.EnvironmentProfilePayload {
+ return hookspkg.EnvironmentProfilePayload{
+ Profile: strings.TrimSpace(resolved.Profile),
+ Backend: string(resolved.Backend),
+ SyncMode: string(resolved.SyncMode),
+ Persistence: string(resolved.Persistence),
+ RuntimeRootDir: strings.TrimSpace(resolved.RuntimeRootDir),
+ DestroyOnStop: resolved.DestroyOnStop,
+ Env: mergeEnvironmentEnv(nil, resolved.Env),
+ }
+}
+
+func mergeEnvironmentEnv(base map[string]string, overrides map[string]string) map[string]string {
+ if len(base) == 0 && len(overrides) == 0 {
+ return nil
+ }
+ merged := make(map[string]string, len(base)+len(overrides))
+ for key, value := range base {
+ if trimmed := strings.TrimSpace(key); trimmed != "" {
+ merged[trimmed] = value
+ }
+ }
+ for key, value := range overrides {
+ if trimmed := strings.TrimSpace(key); trimmed != "" {
+ merged[trimmed] = value
+ }
+ }
+ return merged
+}
+
+func applyEnvironmentEnvOverrides(env []string, overrides map[string]string) []string {
+ next := append([]string(nil), env...)
+ keys := make([]string, 0, len(overrides))
+ values := make(map[string]string, len(overrides))
+ for key, value := range overrides {
+ if trimmed := strings.TrimSpace(key); trimmed != "" {
+ keys = append(keys, trimmed)
+ values[trimmed] = value
+ }
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ next = setSessionStartEnvValue(next, key, values[key])
+ }
+ return next
+}
+
+func applyEnvironmentMetaFallbacks(
+ environmentID *string,
+ backend *string,
+ profile *string,
+ instanceID *string,
+ meta *store.SessionEnvironmentMeta,
+) {
+ if meta == nil {
+ return
+ }
+ if environmentID != nil && strings.TrimSpace(*environmentID) == "" {
+ *environmentID = strings.TrimSpace(meta.EnvironmentID)
+ }
+ if backend != nil && strings.TrimSpace(*backend) == "" {
+ *backend = strings.TrimSpace(meta.Backend)
+ }
+ if profile != nil && strings.TrimSpace(*profile) == "" {
+ *profile = strings.TrimSpace(meta.Profile)
+ }
+ if instanceID != nil && strings.TrimSpace(*instanceID) == "" {
+ *instanceID = strings.TrimSpace(meta.InstanceID)
+ }
+}
+
+func (m *Manager) environmentSyncFileCount(session *Session, direction envpkg.SyncDirection) int {
+ if direction != envpkg.SyncDirectionToRuntime || session == nil {
+ return 0
+ }
+ info := session.Info()
+ if info == nil {
+ return 0
+ }
+ root := strings.TrimSpace(info.Workspace)
+ if root == "" {
+ return 0
+ }
+ count, err := countRegularFiles(root)
+ if err != nil {
+ m.sessionLogger(session).Warn("session: count environment sync files failed", "root", root, "error", err)
+ return 0
+ }
+ return count
+}
+
+func countRegularFiles(root string) (int, error) {
+ count := 0
+ err := filepath.WalkDir(root, func(_ string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry == nil || entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if info.Mode().IsRegular() {
+ count++
+ }
+ return nil
+ })
+ if err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+func syncResultErrors(result envpkg.SyncResult, err error) []string {
+ if err == nil && len(result.Errors) == 0 {
+ return nil
+ }
+ errorsList := make([]string, 0, len(result.Errors)+1)
+ seen := make(map[string]struct{}, len(result.Errors)+1)
+ for _, item := range result.Errors {
+ trimmed := strings.TrimSpace(item)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ errorsList = append(errorsList, trimmed)
+ }
+ if err != nil {
+ trimmed := strings.TrimSpace(err.Error())
+ if trimmed != "" {
+ if _, ok := seen[trimmed]; !ok {
+ errorsList = append(errorsList, trimmed)
+ }
+ }
+ }
+ return errorsList
+}
+
+func environmentEventFromMeta(
+ meta *store.SessionEnvironmentMeta,
+ sessionID string,
+ workspaceID string,
+ name string,
+ reason string,
+) EnvironmentLifecycleEvent {
+ event := EnvironmentLifecycleEvent{
+ Name: strings.TrimSpace(name),
+ SessionID: strings.TrimSpace(sessionID),
+ WorkspaceID: strings.TrimSpace(workspaceID),
+ Reason: strings.TrimSpace(reason),
+ }
+ if meta != nil {
+ event.EnvironmentID = strings.TrimSpace(meta.EnvironmentID)
+ event.Backend = strings.TrimSpace(meta.Backend)
+ event.Profile = strings.TrimSpace(meta.Profile)
+ event.InstanceID = strings.TrimSpace(meta.InstanceID)
+ }
+ return event
+}
+
+func attachEnvironmentError(event *EnvironmentLifecycleEvent, err error) {
+ if event == nil || err == nil {
+ return
+ }
+ event.Error = err.Error()
+ event.ErrorKind = environmentErrorKind(err)
+}
+
+func environmentErrorKind(err error) string {
+ switch {
+ case err == nil:
+ return ""
+ case errors.Is(err, context.Canceled):
+ return "context_canceled"
+ case errors.Is(err, context.DeadlineExceeded):
+ return "context_deadline_exceeded"
+ default:
+ return fmt.Sprintf("%T", err)
+ }
+}
+
+func environmentSpanForEvent(name string, reason string) string {
+ switch name {
+ case environmentEventPrepareStart, environmentEventPrepareComplete, environmentEventPrepareError:
+ return "environment.prepare"
+ case environmentEventDestroyStart, environmentEventDestroyComplete, environmentEventDestroyError:
+ return "environment.destroy"
+ case environmentEventSyncStart, environmentEventSyncComplete, environmentEventSyncError:
+ if reason == string(envpkg.SyncReasonStart) || reason == string(envpkg.SyncReasonTurn) {
+ return "environment.sync.to_runtime"
+ }
+ return "environment.sync.from_runtime"
+ default:
+ return strings.TrimSpace(name)
+ }
+}
+
+func cloneSessionEnvironmentMeta(meta *store.SessionEnvironmentMeta) *store.SessionEnvironmentMeta {
+ if meta == nil {
+ return nil
+ }
+ cloned := *meta
+ cloned.RuntimeAdditionalDirs = append([]string(nil), meta.RuntimeAdditionalDirs...)
+ cloned.ProviderState = cloneRawMessage(meta.ProviderState)
+ cloned.SSHAccessExpiresAt = cloneTimePointer(meta.SSHAccessExpiresAt)
+ cloned.LastSyncAt = cloneTimePointer(meta.LastSyncAt)
+ return &cloned
+}
+
+func cloneRawMessage(value json.RawMessage) json.RawMessage {
+ if value == nil {
+ return nil
+ }
+ cloned := make(json.RawMessage, len(value))
+ copy(cloned, value)
+ return cloned
+}
+
+func cloneTimePointer(value *time.Time) *time.Time {
+ if value == nil {
+ return nil
+ }
+ cloned := *value
+ return &cloned
+}
diff --git a/internal/session/environment_exec.go b/internal/session/environment_exec.go
new file mode 100644
index 000000000..70bd25c05
--- /dev/null
+++ b/internal/session/environment_exec.go
@@ -0,0 +1,98 @@
+package session
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+)
+
+const defaultEnvironmentExecTimeout = 30 * time.Second
+
+// EnvironmentExecRequest describes one command execution inside a session environment.
+type EnvironmentExecRequest struct {
+ SessionID string
+ Command string
+ Timeout time.Duration
+}
+
+// EnvironmentExecResult reports the terminal execution result.
+type EnvironmentExecResult struct {
+ ExitCode int
+ Stdout string
+ Stderr string
+}
+
+// ExecEnvironment runs a command through the active session's environment tool host.
+func (m *Manager) ExecEnvironment(ctx context.Context, req EnvironmentExecRequest) (EnvironmentExecResult, error) {
+ if ctx == nil {
+ return EnvironmentExecResult{}, errors.New("session: environment exec context is required")
+ }
+ sessionID := strings.TrimSpace(req.SessionID)
+ if sessionID == "" {
+ return EnvironmentExecResult{}, errors.New("session: environment exec session id is required")
+ }
+ command := strings.TrimSpace(req.Command)
+ if command == "" {
+ return EnvironmentExecResult{}, errors.New("session: environment exec command is required")
+ }
+
+ sess, ok := m.Get(sessionID)
+ if !ok {
+ return EnvironmentExecResult{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID)
+ }
+ info := sess.Info()
+ if info == nil || info.State != StateActive {
+ return EnvironmentExecResult{}, fmt.Errorf("%w: %s", ErrSessionNotActive, sessionID)
+ }
+ if info.Environment == nil {
+ return EnvironmentExecResult{}, errors.New("session: environment is not configured")
+ }
+ process := sess.processHandle()
+ if process == nil {
+ return EnvironmentExecResult{}, errors.New("session: agent process is not available")
+ }
+ toolHost := process.ToolHost()
+ if toolHost == nil {
+ return EnvironmentExecResult{}, errors.New("session: environment tool host is not available")
+ }
+
+ timeout := req.Timeout
+ if timeout <= 0 {
+ timeout = defaultEnvironmentExecTimeout
+ }
+ execCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ cwd := strings.TrimSpace(info.Environment.RuntimeRootDir)
+ createReq := acpsdk.CreateTerminalRequest{Command: command}
+ if cwd != "" {
+ createReq.Cwd = &cwd
+ }
+ terminal, err := toolHost.CreateTerminal(execCtx, createReq)
+ if err != nil {
+ return EnvironmentExecResult{}, fmt.Errorf("session: environment exec create terminal: %w", err)
+ }
+
+ exitCode, waitErr := toolHost.WaitForTerminalExit(execCtx, terminal.TerminalId)
+ output, outputErr := toolHost.TerminalOutput(terminal.TerminalId)
+ releaseErr := toolHost.ReleaseTerminal(terminal.TerminalId)
+
+ result := EnvironmentExecResult{ExitCode: exitCode, Stdout: output}
+ if waitErr != nil {
+ result.Stderr = waitErr.Error()
+ }
+ if outputErr != nil {
+ return result, fmt.Errorf("session: environment exec read output: %w", outputErr)
+ }
+ if releaseErr != nil {
+ return result, fmt.Errorf("session: environment exec release terminal: %w", releaseErr)
+ }
+ if waitErr != nil && execCtx.Err() != nil {
+ return result, fmt.Errorf("session: environment exec wait: %w", waitErr)
+ }
+ return result, nil
+}
diff --git a/internal/session/hooks.go b/internal/session/hooks.go
index 12580f7df..687902159 100644
--- a/internal/session/hooks.go
+++ b/internal/session/hooks.go
@@ -28,6 +28,30 @@ type LifecycleHooks interface {
DispatchSessionPostStop(context.Context, hookspkg.SessionPostStopPayload) (hookspkg.SessionPostStopPayload, error)
}
+// EnvironmentHooks groups execution-environment lifecycle hook dispatch.
+type EnvironmentHooks interface {
+ DispatchEnvironmentPrepare(
+ context.Context,
+ hookspkg.EnvironmentPreparePayload,
+ ) (hookspkg.EnvironmentPreparePayload, error)
+ DispatchEnvironmentReady(
+ context.Context,
+ hookspkg.EnvironmentReadyPayload,
+ ) (hookspkg.EnvironmentReadyPayload, error)
+ DispatchEnvironmentSyncBefore(
+ context.Context,
+ hookspkg.EnvironmentSyncBeforePayload,
+ ) (hookspkg.EnvironmentSyncBeforePayload, error)
+ DispatchEnvironmentSyncAfter(
+ context.Context,
+ hookspkg.EnvironmentSyncAfterPayload,
+ ) (hookspkg.EnvironmentSyncAfterPayload, error)
+ DispatchEnvironmentStop(
+ context.Context,
+ hookspkg.EnvironmentStopPayload,
+ ) (hookspkg.EnvironmentStopPayload, error)
+}
+
// PromptHooks groups prompt assembly and user-input hook dispatch.
type PromptHooks interface {
DispatchInputPreSubmit(context.Context, hookspkg.InputPreSubmitPayload) (hookspkg.InputPreSubmitPayload, error)
@@ -73,6 +97,7 @@ type CompactionHooks interface {
// no-op implementations so callers only provide the domains they exercise.
type HookSet struct {
Session LifecycleHooks
+ Environment EnvironmentHooks
Prompt PromptHooks
Events EventHooks
Agent AgentHooks
@@ -81,6 +106,7 @@ type HookSet struct {
}
var _ LifecycleHooks = noopSessionLifecycleHooks{}
+var _ EnvironmentHooks = noopEnvironmentHooks{}
var _ PromptHooks = noopPromptHooks{}
var _ EventHooks = noopEventHooks{}
var _ AgentHooks = noopAgentHooks{}
@@ -94,6 +120,13 @@ func (h HookSet) session() LifecycleHooks {
return noopSessionLifecycleHooks{}
}
+func (h HookSet) environment() EnvironmentHooks {
+ if h.Environment != nil {
+ return h.Environment
+ }
+ return noopEnvironmentHooks{}
+}
+
func (h HookSet) prompt() PromptHooks {
if h.Prompt != nil {
return h.Prompt
@@ -173,6 +206,43 @@ func (noopSessionLifecycleHooks) DispatchSessionPostStop(
return payload, nil
}
+type noopEnvironmentHooks struct{}
+
+func (noopEnvironmentHooks) DispatchEnvironmentPrepare(
+ _ context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+) (hookspkg.EnvironmentPreparePayload, error) {
+ return payload, nil
+}
+
+func (noopEnvironmentHooks) DispatchEnvironmentReady(
+ _ context.Context,
+ payload hookspkg.EnvironmentReadyPayload,
+) (hookspkg.EnvironmentReadyPayload, error) {
+ return payload, nil
+}
+
+func (noopEnvironmentHooks) DispatchEnvironmentSyncBefore(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ return payload, nil
+}
+
+func (noopEnvironmentHooks) DispatchEnvironmentSyncAfter(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncAfterPayload,
+) (hookspkg.EnvironmentSyncAfterPayload, error) {
+ return payload, nil
+}
+
+func (noopEnvironmentHooks) DispatchEnvironmentStop(
+ _ context.Context,
+ payload hookspkg.EnvironmentStopPayload,
+) (hookspkg.EnvironmentStopPayload, error) {
+ return payload, nil
+}
+
type noopPromptHooks struct{}
func (noopPromptHooks) DispatchInputPreSubmit(
diff --git a/internal/session/interfaces.go b/internal/session/interfaces.go
index 90bc66f91..285220353 100644
--- a/internal/session/interfaces.go
+++ b/internal/session/interfaces.go
@@ -9,6 +9,7 @@ import (
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/store"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
@@ -65,6 +66,8 @@ type AgentProcess struct {
stderrFn func() string
approvePermissionFn func(context.Context, acp.ApproveRequest) error
configureRuntimeFn func(func() TurnSource)
+ toolHostFn func() environment.ToolHost
+ toolHost environment.ToolHost
native any
}
@@ -83,6 +86,7 @@ type AgentProcessOptions struct {
Stderr func() string
ApprovePermission func(context.Context, acp.ApproveRequest) error
ConfigureRuntime func(func() TurnSource)
+ ToolHost environment.ToolHost
}
// NewAgentProcess constructs an AgentProcess for custom AgentDriver implementations.
@@ -121,6 +125,7 @@ func NewAgentProcess(opts AgentProcessOptions) *AgentProcess {
stderrFn: stderrFn,
approvePermissionFn: opts.ApprovePermission,
configureRuntimeFn: opts.ConfigureRuntime,
+ toolHost: opts.ToolHost,
}
}
@@ -140,6 +145,17 @@ func (p *AgentProcess) Stderr() string {
return p.stderrFn()
}
+// ToolHost returns the environment-owned tool host when the process exposes one.
+func (p *AgentProcess) ToolHost() environment.ToolHost {
+ if p == nil {
+ return nil
+ }
+ if p.toolHostFn != nil {
+ return p.toolHostFn()
+ }
+ return p.toolHost
+}
+
// ApprovePermission resolves one pending interactive permission request.
func (p *AgentProcess) ApprovePermission(ctx context.Context, req acp.ApproveRequest) error {
if p.approvePermissionFn == nil {
@@ -186,7 +202,8 @@ func wrapACPProcess(proc *acp.AgentProcess) *AgentProcess {
return string(currentTurnSource())
})
},
- native: proc,
+ toolHostFn: proc.ToolHost,
+ native: proc,
}
}
@@ -208,11 +225,22 @@ type Notifier interface {
OnAgentEvent(ctx context.Context, sessionID string, event any)
}
+// AgentEventNotifier is an optional notifier extension that receives the
+// active session alongside streamed agent events.
+type AgentEventNotifier interface {
+ OnAgentEventForSession(ctx context.Context, session *Session, event any)
+}
+
// PromptAssembler assembles the prompt context for a new session start.
type PromptAssembler interface {
Assemble(ctx context.Context, agent aghconfig.AgentDef, workspace *workspacepkg.ResolvedWorkspace) (string, error)
}
+// AgentResolver resolves agent definitions from the daemon-authoritative catalog.
+type AgentResolver interface {
+ ResolveAgent(name string, resolved *workspacepkg.ResolvedWorkspace) (aghconfig.AgentDef, error)
+}
+
// SkillRegistry resolves the active skill set for a workspace during session start.
type SkillRegistry interface {
ForWorkspace(ctx context.Context, resolved *workspacepkg.ResolvedWorkspace) ([]*skillspkg.Skill, error)
diff --git a/internal/session/manager.go b/internal/session/manager.go
index 485705876..4baab76fd 100644
--- a/internal/session/manager.go
+++ b/internal/session/manager.go
@@ -12,6 +12,7 @@ import (
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/store/sessiondb"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -60,24 +61,34 @@ type Manager struct {
pending map[string]struct{}
finalizing map[string]chan struct{}
- logger *slog.Logger
- driver AgentDriver
- notifier Notifier
- networkPeers NetworkPeerLifecycle
- turnEndNotifier TurnEndNotifier
- hooks HookSet
- skillRegistry SkillRegistry
- mcpResolver MCPResolver
- homePaths aghconfig.HomePaths
- workspace workspacepkg.RuntimeResolver
- openStore StoreOpener
- assembler PromptAssembler
- lifecycleCtx context.Context
- now func() time.Time
- newSessionID IDGenerator
- newTurnID IDGenerator
- maxSessions int
- promptBufSize int
+ logger *slog.Logger
+ driver AgentDriver
+ notifier Notifier
+ networkPeers NetworkPeerLifecycle
+ turnEndNotifier TurnEndNotifier
+ hooks HookSet
+ environment *environment.Registry
+ agentResolver AgentResolver
+ skillRegistry SkillRegistry
+ mcpResolver MCPResolver
+ homePaths aghconfig.HomePaths
+ workspace workspacepkg.RuntimeResolver
+ openStore StoreOpener
+ assembler PromptAssembler
+ lifecycleCtx context.Context
+ now func() time.Time
+ newSessionID IDGenerator
+ newEnvironmentID IDGenerator
+ newTurnID IDGenerator
+ maxSessions int
+ promptBufSize int
+}
+
+// WithEnvironmentRegistry injects the runtime environment provider registry.
+func WithEnvironmentRegistry(registry *environment.Registry) Option {
+ return func(manager *Manager) {
+ manager.environment = registry
+ }
}
// WithDriver injects the runtime driver used for session lifecycle operations.
@@ -130,6 +141,13 @@ func WithSkillRegistry(registry SkillRegistry) Option {
}
}
+// WithAgentResolver injects the daemon-authoritative agent definition resolver.
+func WithAgentResolver(resolver AgentResolver) Option {
+ return func(manager *Manager) {
+ manager.agentResolver = resolver
+ }
+}
+
// WithMCPResolver injects the skill MCP resolver used during session start.
func WithMCPResolver(resolver MCPResolver) Option {
return func(manager *Manager) {
@@ -172,6 +190,13 @@ func WithSessionIDGenerator(generator IDGenerator) Option {
}
}
+// WithEnvironmentIDGenerator overrides environment id allocation.
+func WithEnvironmentIDGenerator(generator IDGenerator) Option {
+ return func(manager *Manager) {
+ manager.newEnvironmentID = generator
+ }
+}
+
// WithTurnIDGenerator overrides prompt turn id allocation.
func WithTurnIDGenerator(generator IDGenerator) Option {
return func(manager *Manager) {
@@ -217,6 +242,9 @@ func NewManager(opts ...Option) (*Manager, error) {
newSessionID: func() string {
return newID("sess")
},
+ newEnvironmentID: func() string {
+ return newID("env")
+ },
newTurnID: func() string {
return newID("turn")
},
@@ -251,6 +279,11 @@ func NewManager(opts ...Option) (*Manager, error) {
return newID("sess")
}
}
+ if manager.newEnvironmentID == nil {
+ manager.newEnvironmentID = func() string {
+ return newID("env")
+ }
+ }
if manager.newTurnID == nil {
manager.newTurnID = func() string {
return newID("turn")
diff --git a/internal/session/manager_environment_test.go b/internal/session/manager_environment_test.go
new file mode 100644
index 000000000..d827b221f
--- /dev/null
+++ b/internal/session/manager_environment_test.go
@@ -0,0 +1,1202 @@
+package session
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "maps"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ acpsdk "github.com/coder/acp-go-sdk"
+
+ "github.com/pedronauck/agh/internal/acp"
+ "github.com/pedronauck/agh/internal/environment"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/store"
+ "github.com/pedronauck/agh/internal/testutil"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+func TestSessionEnvironmentStartPrepareSyncAndLaunchSequence(t *testing.T) {
+ t.Parallel()
+
+ runtimeRoot := filepath.Join(t.TempDir(), "runtime-root")
+ runtimeAdditional := []string{filepath.Join(t.TempDir(), "runtime-extra")}
+ providerState := json.RawMessage(`{"prepared":true}`)
+ var (
+ h *harness
+ mu sync.Mutex
+ order []string
+ )
+ appendOrder := func(entry string) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, entry)
+ }
+
+ provider := &recordingEnvironmentProvider{
+ runtimeRoot: runtimeRoot,
+ runtimeAdditional: runtimeAdditional,
+ instanceID: "instance-start",
+ providerState: providerState,
+ prepareHook: func(req environment.PrepareRequest) error {
+ appendOrder("prepare")
+ meta := readMeta(t, store.SessionMetaFile(filepath.Join(h.homePaths.SessionsDir, req.SessionID)))
+ if meta.Environment == nil {
+ t.Fatal("persisted environment before Prepare = nil, want creating metadata")
+ }
+ if got, want := meta.Environment.EnvironmentID, "env-1"; got != want {
+ t.Fatalf("creating environment id = %q, want %q", got, want)
+ }
+ if got, want := meta.Environment.State, environmentStateCreating; got != want {
+ t.Fatalf("creating environment state = %q, want %q", got, want)
+ }
+ return nil
+ },
+ syncToHook: func(state environment.SessionState, opts environment.SyncOptions) (environment.SyncResult, error) {
+ appendOrder("sync_to")
+ if got, want := opts.Reason, environment.SyncReasonStart; got != want {
+ t.Fatalf("SyncToRuntime reason = %q, want %q", got, want)
+ }
+ if got, want := state.EnvironmentID, "env-1"; got != want {
+ t.Fatalf("SyncToRuntime environment id = %q, want %q", got, want)
+ }
+ if got, want := state.RuntimeRootDir, runtimeRoot; got != want {
+ t.Fatalf("SyncToRuntime runtime root = %q, want %q", got, want)
+ }
+ return environment.SyncResult{}, nil
+ },
+ }
+ registry := newRegistryForProvider(t, provider)
+ h = newHarness(t, WithEnvironmentRegistry(registry), WithEnvironmentIDGenerator(sequentialIDGenerator("env")))
+ h.driver.startHook = func(opts acp.StartOpts, _ int) (*fakeProcess, error) {
+ appendOrder("launch")
+ if got, want := opts.Cwd, runtimeRoot; got != want {
+ t.Fatalf("StartOpts.Cwd = %q, want runtime root %q", got, want)
+ }
+ if !reflect.DeepEqual(opts.AdditionalDirs, runtimeAdditional) {
+ t.Fatalf("StartOpts.AdditionalDirs = %#v, want %#v", opts.AdditionalDirs, runtimeAdditional)
+ }
+ if opts.Launcher != nil {
+ t.Fatal("fake provider launcher = non-nil, want fake-driver fallback")
+ }
+ meta := readMeta(t, store.SessionMetaFile(filepath.Join(h.homePaths.SessionsDir, "sess-1")))
+ if got, want := meta.Environment.InstanceID, "instance-start"; got != want {
+ t.Fatalf("persisted instance id before launch = %q, want %q", got, want)
+ }
+ assertJSONEqual(t, meta.Environment.ProviderState, providerState, "persisted provider state before launch")
+ if meta.Environment.LastSyncAt == nil {
+ t.Fatal("persisted LastSyncAt before launch = nil, want sync timestamp")
+ }
+ return newFakeProcess(opts.AgentName, opts.Command, opts.Cwd, "acp-start"), nil
+ }
+
+ session, err := h.manager.Create(testutil.Context(t), CreateOpts{
+ AgentName: "coder",
+ Workspace: h.workspaceID,
+ })
+ if err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+
+ if got, want := provider.prepareRequests[0].EnvironmentID, "env-1"; got != want {
+ t.Fatalf("PrepareRequest.EnvironmentID = %q, want %q", got, want)
+ }
+ if got, want := provider.prepareRequests[0].SessionID, session.ID; got != want {
+ t.Fatalf("PrepareRequest.SessionID = %q, want %q", got, want)
+ }
+ if got, want := provider.prepareRequests[0].WorkspaceID, h.workspaceID; got != want {
+ t.Fatalf("PrepareRequest.WorkspaceID = %q, want %q", got, want)
+ }
+ if got, want := orderSnapshot(&mu, order), []string{"prepare", "sync_to", "launch"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("environment order = %#v, want %#v", got, want)
+ }
+ if info := session.Info(); info.Environment == nil || info.Environment.EnvironmentID != "env-1" {
+ t.Fatalf("session.Info().Environment = %#v, want env-1", info.Environment)
+ }
+}
+
+func TestSessionEnvironmentStopSyncsBeforeRecorderCloseAndDestroyPolicy(t *testing.T) {
+ t.Parallel()
+
+ for _, tt := range []struct {
+ name string
+ destroyOnStop bool
+ wantState string
+ wantOrder []string
+ }{
+ {
+ name: "keeps environment when destroy on stop is false",
+ destroyOnStop: false,
+ wantState: environmentStateStopped,
+ wantOrder: []string{"sync_from", "close"},
+ },
+ {
+ name: "destroys environment when destroy on stop is true",
+ destroyOnStop: true,
+ wantState: environmentStateDestroyed,
+ wantOrder: []string{"sync_from", "destroy", "close"},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ mu sync.Mutex
+ order []string
+ )
+ appendOrder := func(entry string) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, entry)
+ }
+ provider := &recordingEnvironmentProvider{
+ syncFromHook: func(_ environment.SessionState, opts environment.SyncOptions) (environment.SyncResult, error) {
+ appendOrder("sync_from")
+ if got, want := opts.Reason, environment.SyncReasonStop; got != want {
+ t.Fatalf("SyncFromRuntime reason = %q, want %q", got, want)
+ }
+ return environment.SyncResult{}, nil
+ },
+ destroyHook: func(environment.SessionState) error {
+ appendOrder("destroy")
+ return nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithStore(func(context.Context, string, string) (EventRecorder, error) {
+ return &orderingRecorder{onClose: func() { appendOrder("close") }}, nil
+ }),
+ )
+ setHarnessEnvironment(t, h, tt.destroyOnStop)
+ session := createSession(t, h)
+
+ if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+
+ if got := orderSnapshot(&mu, order); !reflect.DeepEqual(got, tt.wantOrder) {
+ t.Fatalf("stop environment order = %#v, want %#v", got, tt.wantOrder)
+ }
+ meta := readMeta(t, session.MetaPath())
+ if got := meta.Environment.State; got != tt.wantState {
+ t.Fatalf("environment state after stop = %q, want %q", got, tt.wantState)
+ }
+ })
+ }
+}
+
+func TestSessionEnvironmentCrashSyncIsBestEffort(t *testing.T) {
+ t.Parallel()
+
+ syncErr := errors.New("runtime sync failed")
+ provider := &recordingEnvironmentProvider{
+ syncFromHook: func(_ environment.SessionState, opts environment.SyncOptions) (environment.SyncResult, error) {
+ if got, want := opts.Reason, environment.SyncReasonCrash; got != want {
+ t.Fatalf("SyncFromRuntime reason = %q, want %q", got, want)
+ }
+ return environment.SyncResult{}, syncErr
+ },
+ }
+ h := newHarness(t, WithEnvironmentRegistry(newRegistryForProvider(t, provider)))
+ session := createSession(t, h)
+
+ h.driver.lastProcess().crash(errors.New("boom"), "stderr trace")
+ waitForCondition(t, "session stopped after crash sync failure", func() bool {
+ return h.notifier.stoppedCount() == 1
+ })
+
+ meta := readMeta(t, session.MetaPath())
+ if got, want := meta.State, string(StateStopped); got != want {
+ t.Fatalf("session state after crash = %q, want %q", got, want)
+ }
+ if meta.StopReason == nil || *meta.StopReason != store.StopAgentCrashed {
+ t.Fatalf("StopReason after crash = %#v, want agent_crashed", meta.StopReason)
+ }
+ if got := meta.Environment.LastSyncError; got != syncErr.Error() {
+ t.Fatalf("environment LastSyncError = %q, want %q", got, syncErr.Error())
+ }
+}
+
+func TestSessionEnvironmentResumeRestoresProviderState(t *testing.T) {
+ t.Parallel()
+
+ provider := &recordingEnvironmentProvider{}
+ h := newHarness(t, WithEnvironmentRegistry(newRegistryForProvider(t, provider)))
+ session := createSession(t, h)
+ if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+
+ meta := readMeta(t, session.MetaPath())
+ meta.Environment.EnvironmentID = "env-resume"
+ meta.Environment.InstanceID = "instance-resume"
+ meta.Environment.ProviderState = json.RawMessage(`{"resume":true}`)
+ if err := store.WriteSessionMeta(session.MetaPath(), meta); err != nil {
+ t.Fatalf("WriteSessionMeta() error = %v", err)
+ }
+
+ resumed, err := h.manager.Resume(testutil.Context(t), session.ID)
+ if err != nil {
+ t.Fatalf("Resume() error = %v", err)
+ }
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), resumed.ID)
+ })
+
+ if got, want := len(provider.prepareRequests), 2; got != want {
+ t.Fatalf("Prepare calls = %d, want %d", got, want)
+ }
+ req := provider.prepareRequests[1]
+ if got, want := req.EnvironmentID, "env-resume"; got != want {
+ t.Fatalf("resume PrepareRequest.EnvironmentID = %q, want %q", got, want)
+ }
+ if got, want := req.InstanceID, "instance-resume"; got != want {
+ t.Fatalf("resume PrepareRequest.InstanceID = %q, want %q", got, want)
+ }
+ assertJSONEqual(t, req.ProviderState, json.RawMessage(`{"resume":true}`), "resume PrepareRequest.ProviderState")
+}
+
+func TestSessionEnvironmentLifecycleObserverReceivesRequiredFields(t *testing.T) {
+ t.Parallel()
+
+ observer := &recordingEnvironmentNotifier{}
+ h := newHarness(t, WithNotifier(observer))
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+
+ events := observer.eventsSnapshot()
+ if len(events) == 0 {
+ t.Fatal("environment lifecycle events = empty, want events")
+ }
+ for _, event := range events {
+ if event.SessionID != session.ID {
+ t.Fatalf("event.SessionID = %q, want %q in %#v", event.SessionID, session.ID, event)
+ }
+ if event.WorkspaceID != h.workspaceID {
+ t.Fatalf("event.WorkspaceID = %q, want %q in %#v", event.WorkspaceID, h.workspaceID, event)
+ }
+ if event.EnvironmentID == "" || event.Backend == "" || event.Profile == "" {
+ t.Fatalf("event missing environment fields: %#v", event)
+ }
+ if event.Name == "" || event.Span == "" {
+ t.Fatalf("event missing name/span: %#v", event)
+ }
+ }
+}
+
+func TestSessionEnvironmentHooksDispatchPayloadsAcrossLifecycle(t *testing.T) {
+ t.Parallel()
+
+ if err := os.WriteFile(filepath.Join(t.TempDir(), "unrelated.txt"), []byte("ignored"), 0o644); err != nil {
+ t.Fatalf("WriteFile(unrelated) error = %v", err)
+ }
+ provider := &recordingEnvironmentProvider{
+ instanceID: "instance-hooked",
+ syncToResult: environment.SyncResult{
+ FilesSynced: 2,
+ BytesTransferred: 17,
+ Errors: []string{"provider warning"},
+ },
+ syncFromResult: environment.SyncResult{
+ FilesSynced: 1,
+ BytesTransferred: 9,
+ },
+ }
+ hooks := &recordingEnvironmentHooks{}
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithEnvironmentIDGenerator(sequentialIDGenerator("env")),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+ if err := os.WriteFile(filepath.Join(h.workspace, "tracked.txt"), []byte("workspace"), 0o644); err != nil {
+ t.Fatalf("WriteFile(tracked) error = %v", err)
+ }
+
+ session, err := h.manager.Create(testutil.Context(t), CreateOpts{
+ AgentName: "coder",
+ Workspace: h.workspaceID,
+ })
+ if err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ events := hooks.eventsSnapshot()
+ wantStart := []string{
+ "environment.prepare",
+ "environment.sync.before:to_runtime",
+ "environment.sync.after:to_runtime",
+ "environment.ready",
+ }
+ if !reflect.DeepEqual(events, wantStart) {
+ t.Fatalf("start environment hook events = %#v, want %#v", events, wantStart)
+ }
+
+ prepare := hooks.prepareSnapshot()[0]
+ if prepare.EnvironmentID != "env-1" || prepare.WorkspaceID != h.workspaceID || prepare.AgentName != "coder" {
+ t.Fatalf("environment.prepare payload = %#v, want env/session context", prepare)
+ }
+ if prepare.Profile.Profile == "" || prepare.Backend != string(environment.BackendLocal) {
+ t.Fatalf("environment.prepare profile/backend = %#v/%q, want local profile", prepare.Profile, prepare.Backend)
+ }
+
+ syncBefore := hooks.syncBeforeSnapshot()[0]
+ if syncBefore.Direction != string(environment.SyncDirectionToRuntime) ||
+ syncBefore.Reason != string(environment.SyncReasonStart) {
+ t.Fatalf("sync.before start direction/reason = %q/%q", syncBefore.Direction, syncBefore.Reason)
+ }
+ if syncBefore.FileCount != 1 {
+ t.Fatalf("sync.before file_count = %d, want 1", syncBefore.FileCount)
+ }
+ syncAfter := hooks.syncAfterSnapshot()[0]
+ if syncAfter.FilesSynced != 2 || syncAfter.BytesTransferred != 17 {
+ t.Fatalf("sync.after stats = files %d bytes %d, want 2/17", syncAfter.FilesSynced, syncAfter.BytesTransferred)
+ }
+ if !reflect.DeepEqual(syncAfter.Errors, []string{"provider warning"}) {
+ t.Fatalf("sync.after errors = %#v, want provider warning", syncAfter.Errors)
+ }
+ if syncAfter.DurationMS < 0 {
+ t.Fatalf("sync.after duration_ms = %d, want non-negative", syncAfter.DurationMS)
+ }
+ ready := hooks.readySnapshot()[0]
+ if ready.EnvironmentID != "env-1" || ready.InstanceID != "instance-hooked" {
+ t.Fatalf("environment.ready payload = %#v, want env-1/instance-hooked", ready)
+ }
+
+ if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+ events = hooks.eventsSnapshot()
+ wantAll := []string{
+ "environment.prepare",
+ "environment.sync.before:to_runtime",
+ "environment.sync.after:to_runtime",
+ "environment.ready",
+ "environment.sync.before:from_runtime",
+ "environment.sync.after:from_runtime",
+ "environment.stop",
+ }
+ if !reflect.DeepEqual(events, wantAll) {
+ t.Fatalf("environment hook events = %#v, want %#v", events, wantAll)
+ }
+ stop := hooks.stopSnapshot()[0]
+ if stop.EnvironmentID != "env-1" || stop.InstanceID != "instance-hooked" {
+ t.Fatalf("environment.stop payload = %#v, want env-1/instance-hooked", stop)
+ }
+}
+
+func TestSessionEnvironmentPrepareHookDenyAbortsSessionCreation(t *testing.T) {
+ t.Parallel()
+
+ provider := &recordingEnvironmentProvider{
+ prepareHook: func(environment.PrepareRequest) error {
+ t.Fatal("Prepare() called after environment.prepare denial")
+ return nil
+ },
+ }
+ hooks := &recordingEnvironmentHooks{
+ prepareFn: func(
+ _ context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+ ) (hookspkg.EnvironmentPreparePayload, error) {
+ payload.Denied = true
+ payload.DenyReason = "policy"
+ return payload, nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+
+ _, err := h.manager.Create(testutil.Context(t), CreateOpts{
+ AgentName: "coder",
+ Workspace: h.workspaceID,
+ })
+ if err == nil {
+ t.Fatal("Create() error = nil, want prepare denied error")
+ }
+ if !strings.Contains(err.Error(), "environment prepare denied") {
+ t.Fatalf("Create() error = %v, want environment prepare denied", err)
+ }
+ if got := len(provider.prepareRequests); got != 0 {
+ t.Fatalf("provider Prepare calls = %d, want 0", got)
+ }
+}
+
+func TestSessionEnvironmentPrepareHookEnvOverridesMergeIntoEnvironmentConfig(t *testing.T) {
+ t.Parallel()
+
+ provider := &recordingEnvironmentProvider{}
+ hooks := &recordingEnvironmentHooks{
+ prepareFn: func(
+ _ context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+ ) (hookspkg.EnvironmentPreparePayload, error) {
+ payload.EnvOverrides = map[string]string{
+ "BASE": "patched",
+ "SECRET": "token",
+ }
+ return payload, nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+ resolved, err := h.resolver.Resolve(context.Background(), h.workspaceID)
+ if err != nil {
+ t.Fatalf("Resolve(%q) error = %v", h.workspaceID, err)
+ }
+ resolved.Environment.Env = map[string]string{"BASE": "original"}
+ h.resolver.upsert(&resolved)
+
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+
+ req := provider.prepareRequests[0]
+ if got := req.Environment.Env["BASE"]; got != "patched" {
+ t.Fatalf("PrepareRequest.Environment.Env[BASE] = %q, want patched", got)
+ }
+ if got := req.Environment.Env["SECRET"]; got != "token" {
+ t.Fatalf("PrepareRequest.Environment.Env[SECRET] = %q, want token", got)
+ }
+ if !stringSliceContains(req.AgentEnv, "BASE=patched") || !stringSliceContains(req.AgentEnv, "SECRET=token") {
+ t.Fatalf("PrepareRequest.AgentEnv = %#v, want patched env overrides", req.AgentEnv)
+ }
+}
+
+func TestSessionEnvironmentSyncBeforeDenySkipsSyncOperation(t *testing.T) {
+ t.Parallel()
+
+ provider := &recordingEnvironmentProvider{
+ syncToHook: func(environment.SessionState, environment.SyncOptions) (environment.SyncResult, error) {
+ t.Fatal("SyncToRuntime() called after environment.sync.before denial")
+ return environment.SyncResult{}, nil
+ },
+ }
+ hooks := &recordingEnvironmentHooks{
+ syncBeforeFn: func(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+ ) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ if payload.Direction == string(environment.SyncDirectionToRuntime) {
+ payload.Denied = true
+ payload.DenyReason = "skip initial sync"
+ }
+ return payload, nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+ if got := len(provider.syncToReasons); got != 0 {
+ t.Fatalf("SyncToRuntime calls = %d, want 0", got)
+ }
+ if before := hooks.syncBeforeSnapshot()[0]; !before.Denied || before.DenyReason != "skip initial sync" {
+ t.Fatalf("sync.before denied payload = %#v, want skip denial", before)
+ }
+}
+
+func TestSessionEnvironmentSyncBeforeExcludePatternsPassToProvider(t *testing.T) {
+ t.Parallel()
+
+ wantPatterns := []string{"node_modules/**", "*.log"}
+ provider := &recordingEnvironmentProvider{
+ syncToHook: func(_ environment.SessionState, opts environment.SyncOptions) (environment.SyncResult, error) {
+ if !reflect.DeepEqual(opts.ExcludePatterns, wantPatterns) {
+ t.Fatalf("SyncToRuntime ExcludePatterns = %#v, want %#v", opts.ExcludePatterns, wantPatterns)
+ }
+ return environment.SyncResult{}, nil
+ },
+ }
+ hooks := &recordingEnvironmentHooks{
+ syncBeforeFn: func(
+ _ context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+ ) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ if payload.Direction == string(environment.SyncDirectionToRuntime) {
+ payload.ExcludePatterns = append([]string(nil), wantPatterns...)
+ }
+ return payload, nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+ if got := len(provider.syncToOptions); got != 1 {
+ t.Fatalf("SyncToRuntime calls = %d, want 1", got)
+ }
+ if !reflect.DeepEqual(provider.syncToOptions[0].ExcludePatterns, wantPatterns) {
+ t.Fatalf("recorded ExcludePatterns = %#v, want %#v", provider.syncToOptions[0].ExcludePatterns, wantPatterns)
+ }
+}
+
+func TestSessionEnvironmentStopDenyPreventsDestroyButStopsSession(t *testing.T) {
+ t.Parallel()
+
+ provider := &recordingEnvironmentProvider{
+ destroyHook: func(environment.SessionState) error {
+ t.Fatal("Destroy() called after environment.stop denial")
+ return nil
+ },
+ }
+ hooks := &recordingEnvironmentHooks{
+ stopFn: func(
+ _ context.Context,
+ payload hookspkg.EnvironmentStopPayload,
+ ) (hookspkg.EnvironmentStopPayload, error) {
+ payload.Denied = true
+ payload.DenyReason = "retain sandbox"
+ return payload, nil
+ },
+ }
+ h := newHarness(
+ t,
+ WithEnvironmentRegistry(newRegistryForProvider(t, provider)),
+ WithHookSet(HookSet{Environment: hooks}),
+ )
+ setHarnessEnvironment(t, h, true)
+ session := createSession(t, h)
+
+ if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+ info, err := h.manager.Status(testutil.Context(t), session.ID)
+ if err != nil {
+ t.Fatalf("Status(%q) error = %v", session.ID, err)
+ }
+ if info.State != StateStopped {
+ t.Fatalf("session state = %q, want stopped", info.State)
+ }
+ meta := readMeta(t, session.MetaPath())
+ if got := meta.Environment.State; got != environmentStateStopped {
+ t.Fatalf("environment state = %q, want %q", got, environmentStateStopped)
+ }
+ if got := len(provider.destroyStates); got != 0 {
+ t.Fatalf("Destroy calls = %d, want 0", got)
+ }
+ stop := hooks.stopSnapshot()[0]
+ if !stop.Denied || stop.WillDestroy != true {
+ t.Fatalf("environment.stop payload = %#v, want denied with initial will_destroy", stop)
+ }
+}
+
+func TestManagerExecEnvironmentUsesPreparedToolHost(t *testing.T) {
+ t.Parallel()
+
+ runtimeRoot := filepath.Join(t.TempDir(), "runtime")
+ toolHost := &recordingEnvironmentToolHost{exitCode: 7, output: "terminal output"}
+ provider := &recordingEnvironmentProvider{
+ runtimeRoot: runtimeRoot,
+ toolHost: toolHost,
+ }
+ h := newHarness(t, WithEnvironmentRegistry(newRegistryForProvider(t, provider)))
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+
+ result, err := h.manager.ExecEnvironment(testutil.Context(t), EnvironmentExecRequest{
+ SessionID: session.ID,
+ Command: " echo ready ",
+ Timeout: time.Second,
+ })
+ if err != nil {
+ t.Fatalf("ExecEnvironment() error = %v", err)
+ }
+ if result.ExitCode != 7 || result.Stdout != "terminal output" || result.Stderr != "" {
+ t.Fatalf("ExecEnvironment() result = %#v, want exit/output without stderr", result)
+ }
+
+ toolHost.mu.Lock()
+ defer toolHost.mu.Unlock()
+ if len(toolHost.createRequests) != 1 {
+ t.Fatalf("CreateTerminal calls = %d, want 1", len(toolHost.createRequests))
+ }
+ req := toolHost.createRequests[0]
+ if req.Command != "echo ready" {
+ t.Fatalf("CreateTerminal command = %q, want trimmed command", req.Command)
+ }
+ if req.Cwd == nil || *req.Cwd != runtimeRoot {
+ t.Fatalf("CreateTerminal cwd = %v, want %q", req.Cwd, runtimeRoot)
+ }
+ if got, want := toolHost.waitIDs, []string{"term-1"}; !slices.Equal(got, want) {
+ t.Fatalf("WaitForTerminalExit ids = %v, want %v", got, want)
+ }
+ if got, want := toolHost.outputIDs, []string{"term-1"}; !slices.Equal(got, want) {
+ t.Fatalf("TerminalOutput ids = %v, want %v", got, want)
+ }
+ if got, want := toolHost.releaseIDs, []string{"term-1"}; !slices.Equal(got, want) {
+ t.Fatalf("ReleaseTerminal ids = %v, want %v", got, want)
+ }
+}
+
+func TestManagerExecEnvironmentValidationErrors(t *testing.T) {
+ t.Parallel()
+
+ h := newHarness(t)
+ session := createSession(t, h)
+ t.Cleanup(func() {
+ _ = h.manager.Stop(testutil.Context(t), session.ID)
+ })
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ req EnvironmentExecRequest
+ }{
+ {
+ name: "nil context",
+ req: EnvironmentExecRequest{SessionID: session.ID, Command: "pwd"},
+ },
+ {
+ name: "blank session id",
+ ctx: testutil.Context(t),
+ req: EnvironmentExecRequest{Command: "pwd"},
+ },
+ {
+ name: "blank command",
+ ctx: testutil.Context(t),
+ req: EnvironmentExecRequest{SessionID: session.ID},
+ },
+ {
+ name: "missing session",
+ ctx: testutil.Context(t),
+ req: EnvironmentExecRequest{SessionID: "missing", Command: "pwd"},
+ },
+ {
+ name: "missing tool host",
+ ctx: testutil.Context(t),
+ req: EnvironmentExecRequest{SessionID: session.ID, Command: "pwd"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := h.manager.ExecEnvironment(tc.ctx, tc.req); err == nil {
+ t.Fatal("ExecEnvironment() error = nil, want error")
+ }
+ })
+ }
+}
+
+type recordingEnvironmentProvider struct {
+ mu sync.Mutex
+ prepareRequests []environment.PrepareRequest
+ syncToReasons []environment.SyncReason
+ syncFromReasons []environment.SyncReason
+ syncToOptions []environment.SyncOptions
+ syncFromOptions []environment.SyncOptions
+ destroyStates []environment.SessionState
+ runtimeRoot string
+ runtimeAdditional []string
+ instanceID string
+ providerState json.RawMessage
+ syncToResult environment.SyncResult
+ syncFromResult environment.SyncResult
+ toolHost environment.ToolHost
+ prepareHook func(environment.PrepareRequest) error
+ syncToHook func(environment.SessionState, environment.SyncOptions) (environment.SyncResult, error)
+ syncFromHook func(environment.SessionState, environment.SyncOptions) (environment.SyncResult, error)
+ destroyHook func(environment.SessionState) error
+}
+
+func (p *recordingEnvironmentProvider) Backend() environment.Backend {
+ return environment.BackendLocal
+}
+
+func (p *recordingEnvironmentProvider) Prepare(
+ _ context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ p.mu.Lock()
+ p.prepareRequests = append(p.prepareRequests, clonePrepareRequest(req))
+ p.mu.Unlock()
+
+ if p.prepareHook != nil {
+ if err := p.prepareHook(req); err != nil {
+ return environment.Prepared{}, err
+ }
+ }
+
+ runtimeRoot := p.runtimeRoot
+ if runtimeRoot == "" {
+ runtimeRoot = req.LocalRootDir
+ }
+ runtimeAdditional := append([]string(nil), p.runtimeAdditional...)
+ if runtimeAdditional == nil {
+ runtimeAdditional = append([]string(nil), req.LocalAdditionalDirs...)
+ }
+ instanceID := p.instanceID
+ if instanceID == "" {
+ instanceID = req.InstanceID
+ }
+ providerState := append(json.RawMessage(nil), p.providerState...)
+ if providerState == nil {
+ providerState = append(json.RawMessage(nil), req.ProviderState...)
+ }
+ state := environment.SessionState{
+ EnvironmentID: req.EnvironmentID,
+ Backend: environment.BackendLocal,
+ Profile: req.Environment.Profile,
+ State: environmentStatePrepared,
+ InstanceID: instanceID,
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: append([]string(nil), runtimeAdditional...),
+ ProviderState: providerState,
+ PreparedAt: time.Now().UTC(),
+ }
+ return environment.Prepared{
+ State: state,
+ RuntimeRootDir: runtimeRoot,
+ RuntimeAdditionalDirs: append([]string(nil), runtimeAdditional...),
+ ToolHost: p.toolHost,
+ Launch: environment.LaunchSpec{
+ Command: req.AgentCommand,
+ Cwd: runtimeRoot,
+ AdditionalDirs: append([]string(nil), runtimeAdditional...),
+ Env: append([]string(nil), req.AgentEnv...),
+ },
+ }, nil
+}
+
+func (p *recordingEnvironmentProvider) SyncToRuntime(
+ _ context.Context,
+ state environment.SessionState,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ p.mu.Lock()
+ p.syncToReasons = append(p.syncToReasons, opts.Reason)
+ p.syncToOptions = append(p.syncToOptions, cloneSyncOptions(opts))
+ p.mu.Unlock()
+ if p.syncToHook != nil {
+ return p.syncToHook(state, opts)
+ }
+ return cloneSyncResult(p.syncToResult), nil
+}
+
+func (p *recordingEnvironmentProvider) SyncFromRuntime(
+ _ context.Context,
+ state environment.SessionState,
+ opts environment.SyncOptions,
+) (environment.SyncResult, error) {
+ p.mu.Lock()
+ p.syncFromReasons = append(p.syncFromReasons, opts.Reason)
+ p.syncFromOptions = append(p.syncFromOptions, cloneSyncOptions(opts))
+ p.mu.Unlock()
+ if p.syncFromHook != nil {
+ return p.syncFromHook(state, opts)
+ }
+ return cloneSyncResult(p.syncFromResult), nil
+}
+
+func (p *recordingEnvironmentProvider) Destroy(
+ _ context.Context,
+ state environment.SessionState,
+) error {
+ p.mu.Lock()
+ p.destroyStates = append(p.destroyStates, state)
+ p.mu.Unlock()
+ if p.destroyHook != nil {
+ return p.destroyHook(state)
+ }
+ return nil
+}
+
+type recordingEnvironmentToolHost struct {
+ mu sync.Mutex
+ createRequests []acpsdk.CreateTerminalRequest
+ waitIDs []string
+ outputIDs []string
+ releaseIDs []string
+ exitCode int
+ output string
+}
+
+func (h *recordingEnvironmentToolHost) ReadTextFile(context.Context, string) (string, error) {
+ return "", errors.New("test: ReadTextFile not implemented")
+}
+
+func (h *recordingEnvironmentToolHost) WriteTextFile(context.Context, string, string) error {
+ return errors.New("test: WriteTextFile not implemented")
+}
+
+func (h *recordingEnvironmentToolHost) ResolvePath(path string) (string, error) {
+ return path, nil
+}
+
+func (h *recordingEnvironmentToolHost) Authorize(environment.PermissionOperation) error {
+ return nil
+}
+
+func (h *recordingEnvironmentToolHost) PermissionDecision(
+ acpsdk.RequestPermissionRequest,
+) (environment.PermissionDecision, bool) {
+ return "", false
+}
+
+func (h *recordingEnvironmentToolHost) CreateTerminal(
+ _ context.Context,
+ req acpsdk.CreateTerminalRequest,
+) (acpsdk.CreateTerminalResponse, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.createRequests = append(h.createRequests, req)
+ return acpsdk.CreateTerminalResponse{TerminalId: "term-1"}, nil
+}
+
+func (h *recordingEnvironmentToolHost) KillTerminal(string) error {
+ return nil
+}
+
+func (h *recordingEnvironmentToolHost) TerminalOutput(id string) (string, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.outputIDs = append(h.outputIDs, id)
+ return h.output, nil
+}
+
+func (h *recordingEnvironmentToolHost) WaitForTerminalExit(_ context.Context, id string) (int, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.waitIDs = append(h.waitIDs, id)
+ return h.exitCode, nil
+}
+
+func (h *recordingEnvironmentToolHost) ReleaseTerminal(id string) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.releaseIDs = append(h.releaseIDs, id)
+ return nil
+}
+
+type recordingEnvironmentHooks struct {
+ mu sync.Mutex
+ events []string
+ prepare []hookspkg.EnvironmentPreparePayload
+ ready []hookspkg.EnvironmentReadyPayload
+ syncBefore []hookspkg.EnvironmentSyncBeforePayload
+ syncAfter []hookspkg.EnvironmentSyncAfterPayload
+ stop []hookspkg.EnvironmentStopPayload
+ prepareFn func(context.Context, hookspkg.EnvironmentPreparePayload) (hookspkg.EnvironmentPreparePayload, error)
+ readyFn func(context.Context, hookspkg.EnvironmentReadyPayload) (hookspkg.EnvironmentReadyPayload, error)
+ syncBeforeFn func(context.Context, hookspkg.EnvironmentSyncBeforePayload) (hookspkg.EnvironmentSyncBeforePayload, error)
+ syncAfterFn func(context.Context, hookspkg.EnvironmentSyncAfterPayload) (hookspkg.EnvironmentSyncAfterPayload, error)
+ stopFn func(context.Context, hookspkg.EnvironmentStopPayload) (hookspkg.EnvironmentStopPayload, error)
+}
+
+func (h *recordingEnvironmentHooks) DispatchEnvironmentPrepare(
+ ctx context.Context,
+ payload hookspkg.EnvironmentPreparePayload,
+) (hookspkg.EnvironmentPreparePayload, error) {
+ result := payload
+ var err error
+ if h.prepareFn != nil {
+ result, err = h.prepareFn(ctx, payload)
+ }
+ h.mu.Lock()
+ h.events = append(h.events, string(hookspkg.HookEnvironmentPrepare))
+ h.prepare = append(h.prepare, cloneEnvironmentPreparePayload(result))
+ h.mu.Unlock()
+ return result, err
+}
+
+func (h *recordingEnvironmentHooks) DispatchEnvironmentReady(
+ ctx context.Context,
+ payload hookspkg.EnvironmentReadyPayload,
+) (hookspkg.EnvironmentReadyPayload, error) {
+ result := payload
+ var err error
+ if h.readyFn != nil {
+ result, err = h.readyFn(ctx, payload)
+ }
+ h.mu.Lock()
+ h.events = append(h.events, string(hookspkg.HookEnvironmentReady))
+ h.ready = append(h.ready, cloneEnvironmentReadyPayload(result))
+ h.mu.Unlock()
+ return result, err
+}
+
+func (h *recordingEnvironmentHooks) DispatchEnvironmentSyncBefore(
+ ctx context.Context,
+ payload hookspkg.EnvironmentSyncBeforePayload,
+) (hookspkg.EnvironmentSyncBeforePayload, error) {
+ result := payload
+ var err error
+ if h.syncBeforeFn != nil {
+ result, err = h.syncBeforeFn(ctx, payload)
+ }
+ h.mu.Lock()
+ h.events = append(h.events, string(hookspkg.HookEnvironmentSyncBefore)+":"+result.Direction)
+ h.syncBefore = append(h.syncBefore, cloneEnvironmentSyncBeforePayload(result))
+ h.mu.Unlock()
+ return result, err
+}
+
+func (h *recordingEnvironmentHooks) DispatchEnvironmentSyncAfter(
+ ctx context.Context,
+ payload hookspkg.EnvironmentSyncAfterPayload,
+) (hookspkg.EnvironmentSyncAfterPayload, error) {
+ result := payload
+ var err error
+ if h.syncAfterFn != nil {
+ result, err = h.syncAfterFn(ctx, payload)
+ }
+ h.mu.Lock()
+ h.events = append(h.events, string(hookspkg.HookEnvironmentSyncAfter)+":"+result.Direction)
+ h.syncAfter = append(h.syncAfter, cloneEnvironmentSyncAfterPayload(result))
+ h.mu.Unlock()
+ return result, err
+}
+
+func (h *recordingEnvironmentHooks) DispatchEnvironmentStop(
+ ctx context.Context,
+ payload hookspkg.EnvironmentStopPayload,
+) (hookspkg.EnvironmentStopPayload, error) {
+ result := payload
+ var err error
+ if h.stopFn != nil {
+ result, err = h.stopFn(ctx, payload)
+ }
+ h.mu.Lock()
+ h.events = append(h.events, string(hookspkg.HookEnvironmentStop))
+ h.stop = append(h.stop, result)
+ h.mu.Unlock()
+ return result, err
+}
+
+func (h *recordingEnvironmentHooks) eventsSnapshot() []string {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]string(nil), h.events...)
+}
+
+func (h *recordingEnvironmentHooks) prepareSnapshot() []hookspkg.EnvironmentPreparePayload {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]hookspkg.EnvironmentPreparePayload(nil), h.prepare...)
+}
+
+func (h *recordingEnvironmentHooks) readySnapshot() []hookspkg.EnvironmentReadyPayload {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]hookspkg.EnvironmentReadyPayload(nil), h.ready...)
+}
+
+func (h *recordingEnvironmentHooks) syncBeforeSnapshot() []hookspkg.EnvironmentSyncBeforePayload {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]hookspkg.EnvironmentSyncBeforePayload(nil), h.syncBefore...)
+}
+
+func (h *recordingEnvironmentHooks) syncAfterSnapshot() []hookspkg.EnvironmentSyncAfterPayload {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]hookspkg.EnvironmentSyncAfterPayload(nil), h.syncAfter...)
+}
+
+func (h *recordingEnvironmentHooks) stopSnapshot() []hookspkg.EnvironmentStopPayload {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]hookspkg.EnvironmentStopPayload(nil), h.stop...)
+}
+
+type orderingRecorder struct {
+ onClose func()
+}
+
+func (r *orderingRecorder) Record(context.Context, store.SessionEvent) error {
+ return nil
+}
+
+func (r *orderingRecorder) RecordTokenUsage(context.Context, store.TokenUsage) error {
+ return nil
+}
+
+func (r *orderingRecorder) Query(context.Context, store.EventQuery) ([]store.SessionEvent, error) {
+ return nil, nil
+}
+
+func (r *orderingRecorder) History(context.Context, store.EventQuery) ([]store.TurnHistory, error) {
+ return nil, nil
+}
+
+func (r *orderingRecorder) Close(context.Context) error {
+ if r.onClose != nil {
+ r.onClose()
+ }
+ return nil
+}
+
+type recordingEnvironmentNotifier struct {
+ mu sync.Mutex
+ events []EnvironmentLifecycleEvent
+}
+
+func (n *recordingEnvironmentNotifier) OnSessionCreated(context.Context, *Session) {}
+
+func (n *recordingEnvironmentNotifier) OnSessionStopped(context.Context, *Session) {}
+
+func (n *recordingEnvironmentNotifier) OnAgentEvent(context.Context, string, any) {}
+
+func (n *recordingEnvironmentNotifier) OnEnvironmentLifecycleEvent(
+ _ context.Context,
+ event EnvironmentLifecycleEvent,
+) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.events = append(n.events, event)
+}
+
+func (n *recordingEnvironmentNotifier) eventsSnapshot() []EnvironmentLifecycleEvent {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ return append([]EnvironmentLifecycleEvent(nil), n.events...)
+}
+
+func newRegistryForProvider(t *testing.T, provider environment.Provider) *environment.Registry {
+ t.Helper()
+ registry, err := environment.NewRegistry(provider)
+ if err != nil {
+ t.Fatalf("NewRegistry() error = %v", err)
+ }
+ return registry
+}
+
+func setHarnessEnvironment(t *testing.T, h *harness, destroyOnStop bool) {
+ t.Helper()
+ resolved, err := h.resolver.Resolve(context.Background(), h.workspaceID)
+ if err != nil {
+ t.Fatalf("Resolve(%q) error = %v", h.workspaceID, err)
+ }
+ resolved.Environment.DestroyOnStop = destroyOnStop
+ h.resolver.upsert(&resolved)
+}
+
+func clonePrepareRequest(req environment.PrepareRequest) environment.PrepareRequest {
+ cloned := req
+ cloned.LocalAdditionalDirs = append([]string(nil), req.LocalAdditionalDirs...)
+ cloned.AgentEnv = append([]string(nil), req.AgentEnv...)
+ cloned.ProviderState = append(json.RawMessage(nil), req.ProviderState...)
+ cloned.Environment.Env = cloneStringMapForEnvironmentTests(req.Environment.Env)
+ return cloned
+}
+
+func cloneSyncOptions(opts environment.SyncOptions) environment.SyncOptions {
+ cloned := opts
+ cloned.ExcludePatterns = append([]string(nil), opts.ExcludePatterns...)
+ return cloned
+}
+
+func cloneSyncResult(result environment.SyncResult) environment.SyncResult {
+ cloned := result
+ cloned.Errors = append([]string(nil), result.Errors...)
+ return cloned
+}
+
+func cloneEnvironmentPreparePayload(
+ payload hookspkg.EnvironmentPreparePayload,
+) hookspkg.EnvironmentPreparePayload {
+ cloned := payload
+ cloned.LocalAdditionalDirs = append([]string(nil), payload.LocalAdditionalDirs...)
+ cloned.AgentEnv = append([]string(nil), payload.AgentEnv...)
+ cloned.EnvOverrides = cloneStringMapForEnvironmentTests(payload.EnvOverrides)
+ cloned.Profile.Env = cloneStringMapForEnvironmentTests(payload.Profile.Env)
+ return cloned
+}
+
+func cloneEnvironmentReadyPayload(payload hookspkg.EnvironmentReadyPayload) hookspkg.EnvironmentReadyPayload {
+ cloned := payload
+ cloned.RuntimeAdditionalDirs = append([]string(nil), payload.RuntimeAdditionalDirs...)
+ return cloned
+}
+
+func cloneEnvironmentSyncBeforePayload(
+ payload hookspkg.EnvironmentSyncBeforePayload,
+) hookspkg.EnvironmentSyncBeforePayload {
+ cloned := payload
+ cloned.ExcludePatterns = append([]string(nil), payload.ExcludePatterns...)
+ return cloned
+}
+
+func cloneEnvironmentSyncAfterPayload(
+ payload hookspkg.EnvironmentSyncAfterPayload,
+) hookspkg.EnvironmentSyncAfterPayload {
+ cloned := payload
+ cloned.Errors = append([]string(nil), payload.Errors...)
+ return cloned
+}
+
+func cloneStringMapForEnvironmentTests(values map[string]string) map[string]string {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make(map[string]string, len(values))
+ maps.Copy(cloned, values)
+ return cloned
+}
+
+func stringSliceContains(values []string, want string) bool {
+ return slices.Contains(values, want)
+}
+
+func orderSnapshot(mu *sync.Mutex, order []string) []string {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]string(nil), order...)
+}
+
+func assertJSONEqual(t *testing.T, got json.RawMessage, want json.RawMessage, label string) {
+ t.Helper()
+ var gotValue any
+ if err := json.Unmarshal(got, &gotValue); err != nil {
+ t.Fatalf("%s invalid JSON %s: %v", label, string(got), err)
+ }
+ var wantValue any
+ if err := json.Unmarshal(want, &wantValue); err != nil {
+ t.Fatalf("%s invalid expected JSON %s: %v", label, string(want), err)
+ }
+ if !reflect.DeepEqual(gotValue, wantValue) {
+ t.Fatalf("%s = %#v, want %#v", label, gotValue, wantValue)
+ }
+}
+
+var _ environment.Provider = (*recordingEnvironmentProvider)(nil)
+var _ EventRecorder = (*orderingRecorder)(nil)
+var _ EnvironmentLifecycleNotifier = (*recordingEnvironmentNotifier)(nil)
+var _ EnvironmentHooks = (*recordingEnvironmentHooks)(nil)
+var _ workspacepkg.RuntimeResolver = (*fakeWorkspaceResolver)(nil)
diff --git a/internal/session/manager_hooks_test.go b/internal/session/manager_hooks_test.go
index ce4410769..f569905a8 100644
--- a/internal/session/manager_hooks_test.go
+++ b/internal/session/manager_hooks_test.go
@@ -930,6 +930,8 @@ func newNativeHookDispatcher(
hooks := hookspkg.NewHooks(
hookspkg.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
+ hookspkg.WithAsyncWorkerCount(1),
+ hookspkg.WithAsyncQueueCapacity(16),
hookspkg.WithNativeDeclarations(decls),
hookspkg.WithExecutorResolver(func(decl hookspkg.HookDecl) (hookspkg.Executor, error) {
executor := executors[strings.TrimSpace(decl.Name)]
diff --git a/internal/session/manager_integration_test.go b/internal/session/manager_integration_test.go
index 91168d63e..5b021428f 100644
--- a/internal/session/manager_integration_test.go
+++ b/internal/session/manager_integration_test.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/pedronauck/agh/internal/acp"
+ "github.com/pedronauck/agh/internal/environment"
hookspkg "github.com/pedronauck/agh/internal/hooks"
"github.com/pedronauck/agh/internal/skills/bundled"
"github.com/pedronauck/agh/internal/store"
@@ -287,6 +288,143 @@ func TestManagerIntegrationFullLifecycleHooksFireInOrder(t *testing.T) {
}
}
+func TestManagerIntegrationEnvironmentNativeHooksLifecycleOrder(t *testing.T) {
+ var (
+ mu sync.Mutex
+ order []string
+ afterTo = make(chan struct{})
+ ready = make(chan struct{})
+ afterFrom = make(chan struct{})
+ )
+ record := func(event string) {
+ mu.Lock()
+ defer mu.Unlock()
+ order = append(order, event)
+ }
+ waitFor := func(ctx context.Context, ch <-chan struct{}, label string) error {
+ select {
+ case <-ch:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(2 * time.Second):
+ return errors.New("timed out waiting for " + label)
+ }
+ }
+
+ hooks := newNativeHookDispatcher(t,
+ []hookspkg.HookDecl{
+ {
+ Name: "env-prepare",
+ Event: hookspkg.HookEnvironmentPrepare,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ },
+ {
+ Name: "env-sync-before",
+ Event: hookspkg.HookEnvironmentSyncBefore,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ },
+ {
+ Name: "env-sync-after",
+ Event: hookspkg.HookEnvironmentSyncAfter,
+ Mode: hookspkg.HookModeAsync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ },
+ {
+ Name: "env-ready",
+ Event: hookspkg.HookEnvironmentReady,
+ Mode: hookspkg.HookModeAsync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ },
+ {
+ Name: "env-stop",
+ Event: hookspkg.HookEnvironmentStop,
+ Mode: hookspkg.HookModeSync,
+ ExecutorKind: hookspkg.HookExecutorNative,
+ },
+ },
+ map[string]hookspkg.Executor{
+ "env-prepare": hookspkg.NewTypedNativeExecutor(func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.EnvironmentPreparePayload) (hookspkg.EnvironmentPreparePatch, error) {
+ if payload.EnvironmentID == "" || payload.WorkspaceID == "" {
+ return hookspkg.EnvironmentPreparePatch{}, errors.New("environment.prepare missing identity fields")
+ }
+ record("environment.prepare")
+ return hookspkg.EnvironmentPreparePatch{}, nil
+ }),
+ "env-sync-before": hookspkg.NewTypedNativeExecutor(func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.EnvironmentSyncBeforePayload) (hookspkg.EnvironmentSyncBeforePatch, error) {
+ if payload.EnvironmentID == "" || payload.Direction == "" || payload.Reason == "" {
+ return hookspkg.EnvironmentSyncBeforePatch{}, errors.New("environment.sync.before missing lifecycle fields")
+ }
+ record("environment.sync.before:" + payload.Direction)
+ return hookspkg.EnvironmentSyncBeforePatch{}, nil
+ }),
+ "env-sync-after": hookspkg.NewTypedNativeExecutor(func(_ context.Context, _ hookspkg.RegisteredHook, payload hookspkg.EnvironmentSyncAfterPayload) (hookspkg.EnvironmentSyncAfterPatch, error) {
+ if payload.EnvironmentID == "" || payload.Direction == "" || payload.DurationMS < 0 {
+ return hookspkg.EnvironmentSyncAfterPatch{}, errors.New("environment.sync.after missing lifecycle fields")
+ }
+ record("environment.sync.after:" + payload.Direction)
+ switch payload.Direction {
+ case string(environment.SyncDirectionToRuntime):
+ close(afterTo)
+ case string(environment.SyncDirectionFromRuntime):
+ close(afterFrom)
+ default:
+ return hookspkg.EnvironmentSyncAfterPatch{}, errors.New("unexpected sync direction " + payload.Direction)
+ }
+ return hookspkg.EnvironmentSyncAfterPatch{}, nil
+ }),
+ "env-ready": hookspkg.NewTypedNativeExecutor(func(ctx context.Context, _ hookspkg.RegisteredHook, payload hookspkg.EnvironmentReadyPayload) (hookspkg.EnvironmentReadyPatch, error) {
+ if err := waitFor(ctx, afterTo, "environment.sync.after:to_runtime"); err != nil {
+ return hookspkg.EnvironmentReadyPatch{}, err
+ }
+ if payload.EnvironmentID == "" || payload.RuntimeRootDir == "" {
+ return hookspkg.EnvironmentReadyPatch{}, errors.New("environment.ready missing runtime fields")
+ }
+ record("environment.ready")
+ close(ready)
+ return hookspkg.EnvironmentReadyPatch{}, nil
+ }),
+ "env-stop": hookspkg.NewTypedNativeExecutor(func(ctx context.Context, _ hookspkg.RegisteredHook, payload hookspkg.EnvironmentStopPayload) (hookspkg.EnvironmentStopPatch, error) {
+ if err := waitFor(ctx, afterFrom, "environment.sync.after:from_runtime"); err != nil {
+ return hookspkg.EnvironmentStopPatch{}, err
+ }
+ if payload.EnvironmentID == "" || payload.StopReason == "" {
+ return hookspkg.EnvironmentStopPatch{}, errors.New("environment.stop missing stop fields")
+ }
+ record("environment.stop")
+ return hookspkg.EnvironmentStopPatch{}, nil
+ }),
+ },
+ )
+
+ h := newHarness(t, WithHookSet(HookSet{Environment: hooks}))
+ session := createSession(t, h)
+ if err := waitFor(testutil.Context(t), ready, "environment.ready"); err != nil {
+ t.Fatalf("waiting for environment.ready: %v", err)
+ }
+ if err := h.manager.Stop(testutil.Context(t), session.ID); err != nil {
+ t.Fatalf("Stop() error = %v", err)
+ }
+
+ want := []string{
+ "environment.prepare",
+ "environment.sync.before:to_runtime",
+ "environment.sync.after:to_runtime",
+ "environment.ready",
+ "environment.sync.before:from_runtime",
+ "environment.sync.after:from_runtime",
+ "environment.stop",
+ }
+ mu.Lock()
+ got := append([]string(nil), order...)
+ mu.Unlock()
+ if !testutil.EqualStringSlices(got, want) {
+ t.Fatalf("environment hook order = %#v, want %#v", got, want)
+ }
+}
+
func TestManagerIntegrationContextCompactionUsesPatchedParams(t *testing.T) {
reason := "patched-reason"
strategy := "patched-strategy"
diff --git a/internal/session/manager_lifecycle.go b/internal/session/manager_lifecycle.go
index 4e74a014a..65a8483d4 100644
--- a/internal/session/manager_lifecycle.go
+++ b/internal/session/manager_lifecycle.go
@@ -10,6 +10,7 @@ import (
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/store"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
@@ -161,6 +162,9 @@ func (m *Manager) finalizeStopped(ctx context.Context, session *Session, waitErr
m.dispatchAgentStopped(ctx, session, session.processHandle(), waitErr)
+ m.logEnvironmentTransport(session, environmentEventTransportDisconnect, nil, 0)
+ errs = appendLifecycleErr(errs, m.finalizeEnvironment(ctx, session, environmentSyncReasonForStop(session)))
+
errs = appendLifecycleErr(errs, m.closeSessionRecorder(session))
errs = appendLifecycleErr(errs, m.markSessionStopped(session))
errs = appendLifecycleErr(errs, m.leaveSessionNetwork(ctx, session))
@@ -236,6 +240,17 @@ func (m *Manager) persistStopClassification(session *Session, waitErr error) err
return m.writeMeta(session)
}
+func environmentSyncReasonForStop(session *Session) environment.SyncReason {
+ if session == nil {
+ return environment.SyncReasonStop
+ }
+ info := session.Info()
+ if info != nil && info.StopReason == store.StopAgentCrashed {
+ return environment.SyncReasonCrash
+ }
+ return environment.SyncReasonStop
+}
+
func (m *Manager) recordProcessExitEvent(ctx context.Context, session *Session, waitErr error) error {
if waitErr == nil {
return nil
@@ -258,9 +273,7 @@ func (m *Manager) recordProcessExitEvent(ctx context.Context, session *Session,
if err := m.recordEvent(ctx, session, normalized); err != nil {
return err
}
- if m.notifier != nil {
- m.notifier.OnAgentEvent(ctx, session.ID, normalized)
- }
+ m.notifyAgentEvent(ctx, session, normalized)
return nil
}
@@ -286,9 +299,7 @@ func (m *Manager) recordSessionStoppedEvent(ctx context.Context, session *Sessio
if err := m.recordEvent(ctx, session, normalizedStop); err != nil {
return err
}
- if m.notifier != nil {
- m.notifier.OnAgentEvent(ctx, session.ID, normalizedStop)
- }
+ m.notifyAgentEvent(ctx, session, normalizedStop)
return nil
}
diff --git a/internal/session/manager_prompt.go b/internal/session/manager_prompt.go
index bce8fe1be..f8f2ba9c1 100644
--- a/internal/session/manager_prompt.go
+++ b/internal/session/manager_prompt.go
@@ -155,9 +155,7 @@ func (m *Manager) recordPromptInputEvent(
if err := m.recordEvent(ctx, session, userEvent); err != nil {
return fmt.Errorf("session: persist prompt message for %q: %w", target, err)
}
- if m.notifier != nil {
- m.notifier.OnAgentEvent(ctx, session.ID, userEvent)
- }
+ m.notifyAgentEvent(ctx, session, userEvent)
return nil
}
@@ -239,9 +237,7 @@ func (m *Manager) pumpPrompt(
m.sessionLogger(session).
Warn("session: record prompt event failed", "turn_id", turnState.turnID, "error", err)
}
- if m.notifier != nil {
- m.notifier.OnAgentEvent(ctx, session.ID, normalized)
- }
+ m.notifyAgentEvent(ctx, session, normalized)
select {
case out <- normalized:
diff --git a/internal/session/manager_start.go b/internal/session/manager_start.go
index 5e3e319dd..014649dc1 100644
--- a/internal/session/manager_start.go
+++ b/internal/session/manager_start.go
@@ -19,6 +19,8 @@ import (
type sessionStartSpec struct {
sessionID string
+ environmentID string
+ environment *store.SessionEnvironmentMeta
sessionName string
agentName string
workspace workspacepkg.ResolvedWorkspace
@@ -67,9 +69,14 @@ func (m *Manager) prepareCreateStart(ctx context.Context, opts CreateOpts) (sess
if sessionID == "" {
return sessionStartSpec{}, errors.New("session: session id generator returned empty id")
}
+ environmentID := strings.TrimSpace(m.newEnvironmentID())
+ if environmentID == "" {
+ return sessionStartSpec{}, errors.New("session: environment id generator returned empty id")
+ }
return sessionStartSpec{
sessionID: sessionID,
+ environmentID: environmentID,
sessionName: strings.TrimSpace(opts.Name),
agentName: strings.TrimSpace(agentName),
workspace: resolvedWorkspace,
@@ -94,6 +101,8 @@ func (m *Manager) prepareResumeStart(ctx context.Context, meta store.SessionMeta
return sessionStartSpec{
sessionID: meta.ID,
+ environmentID: sessionEnvironmentID(meta.Environment),
+ environment: cloneSessionEnvironmentMeta(meta.Environment),
sessionName: meta.Name,
agentName: meta.AgentName,
workspace: resolvedWorkspace,
@@ -148,6 +157,10 @@ func (m *Manager) startSession(ctx context.Context, spec *sessionStartSpec) (_ *
session := spec.newStartingSession(runtime.agent, storage, now)
startOpts := m.sessionStartOpts(spec, session, runtime.agent, runtime.mcpServers)
+ startOpts, err = m.prepareEnvironmentForStart(ctx, spec, session, startOpts)
+ if err != nil {
+ return nil, err
+ }
startOpts, err = m.dispatchAgentPreStart(ctx, session, runtime.agent, startOpts)
if err != nil {
return nil, err
@@ -157,10 +170,13 @@ func (m *Manager) startSession(ctx context.Context, spec *sessionStartSpec) (_ *
return nil, err
}
+ transportStarted := time.Now()
proc, err = m.driver.Start(ctx, startOpts)
if err != nil {
+ m.logEnvironmentTransport(session, environmentEventTransportError, err, time.Since(transportStarted))
return nil, fmt.Errorf("session: %s agent for %q: %w", spec.startAction, spec.sessionID, err)
}
+ m.logEnvironmentTransport(session, environmentEventTransportConnect, nil, time.Since(transportStarted))
proc.configureRuntime(session.CurrentTurnSource)
if err := m.activateAndWatch(
@@ -201,7 +217,7 @@ func (m *Manager) prepareSessionStartRuntime(
spec *sessionStartSpec,
updatedAt time.Time,
) (sessionStartRuntime, error) {
- agentDef, err := resolveWorkspaceAgent(spec.agentName, &spec.workspace)
+ agentDef, err := m.resolveWorkspaceAgent(spec.agentName, &spec.workspace)
if err != nil {
return sessionStartRuntime{}, fmt.Errorf("session: resolve workspace agent %q: %w", spec.agentName, err)
}
@@ -268,23 +284,25 @@ func (s *sessionStartSpec) newStartingSession(
}
return &Session{
- ID: s.sessionID,
- Name: s.sessionName,
- AgentName: resolved.Name,
- WorkspaceID: s.workspace.ID,
- Workspace: s.workspace.RootDir,
- Channel: s.channel,
- Type: normalizeSessionType(s.sessionType),
- State: StateStarting,
- stopReason: s.stopReason,
- stopDetail: s.stopDetail,
- ACPSessionID: s.acpSessionID,
- CreatedAt: createdAt,
- UpdatedAt: now,
- sessionDir: storage.sessionDir,
- metaPath: storage.metaPath,
- dbPath: storage.dbPath,
- recorder: storage.recorder,
+ ID: s.sessionID,
+ Name: s.sessionName,
+ AgentName: resolved.Name,
+ WorkspaceID: s.workspace.ID,
+ Workspace: s.workspace.RootDir,
+ Channel: s.channel,
+ Type: normalizeSessionType(s.sessionType),
+ State: StateStarting,
+ stopReason: s.stopReason,
+ stopDetail: s.stopDetail,
+ ACPSessionID: s.acpSessionID,
+ Environment: cloneSessionEnvironmentMeta(s.environment),
+ CreatedAt: createdAt,
+ UpdatedAt: now,
+ sessionDir: storage.sessionDir,
+ metaPath: storage.metaPath,
+ dbPath: storage.dbPath,
+ recorder: storage.recorder,
+ environmentDestroyOnStop: s.workspace.Environment.DestroyOnStop,
}
}
diff --git a/internal/session/manager_stop_integration_test.go b/internal/session/manager_stop_integration_test.go
index 3d1d79df4..15e9844fb 100644
--- a/internal/session/manager_stop_integration_test.go
+++ b/internal/session/manager_stop_integration_test.go
@@ -20,6 +20,7 @@ import (
"github.com/kballard/go-shellquote"
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment/local"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/store/globaldb"
"github.com/pedronauck/agh/internal/testutil"
@@ -208,11 +209,16 @@ func TestManagerIntegrationCreateAndResumeWithWorkspaceResolver(t *testing.T) {
}
driver := acp.New(acp.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))))
+ environmentRegistry, err := local.NewRegistry(local.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))))
+ if err != nil {
+ t.Fatalf("local.NewRegistry() error = %v", err)
+ }
manager, err := NewManager(
WithHomePaths(homePaths),
WithWorkspaceResolver(resolver),
WithDriver(NewACPDriverAdapter(driver)),
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
+ WithEnvironmentRegistry(environmentRegistry),
)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
@@ -233,6 +239,11 @@ func TestManagerIntegrationCreateAndResumeWithWorkspaceResolver(t *testing.T) {
if got, want := session.Info().Workspace, canonicalWorkspaceRoot; got != want {
t.Fatalf("Create() workspace root = %q, want %q", got, want)
}
+ events, err := manager.Prompt(testutil.Context(t), session.ID, "integration prompt")
+ if err != nil {
+ t.Fatalf("Prompt() error = %v", err)
+ }
+ _ = collectEvents(t, events)
if err := manager.Stop(testutil.Context(t), session.ID); err != nil {
t.Fatalf("Stop() error = %v", err)
@@ -294,8 +305,8 @@ func TestManagerIntegrationResumeClassifiesCrashAndActivates(t *testing.T) {
if got := resumed.Info().StopReason; got != store.StopAgentCrashed {
t.Fatalf("resumed stop reason = %q, want %q", got, store.StopAgentCrashed)
}
- if got := resumed.Info().StopDetail; got != "daemon crashed while session active" {
- t.Fatalf("resumed stop detail = %q, want %q", got, "daemon crashed while session active")
+ if got := resumed.Info().StopDetail; got != resumeStopDetailAgentCrashed {
+ t.Fatalf("resumed stop detail = %q, want %q", got, resumeStopDetailAgentCrashed)
}
meta = readMeta(t, resumed.MetaPath())
diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go
index 454426759..ba7cd008d 100644
--- a/internal/session/manager_test.go
+++ b/internal/session/manager_test.go
@@ -19,6 +19,7 @@ import (
acpsdk "github.com/coder/acp-go-sdk"
"github.com/pedronauck/agh/internal/acp"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
hookspkg "github.com/pedronauck/agh/internal/hooks"
skillspkg "github.com/pedronauck/agh/internal/skills"
"github.com/pedronauck/agh/internal/skills/bundled"
@@ -430,8 +431,8 @@ func TestResumePreservesCrashStopClassificationFromRepairedMetadata(t *testing.T
if got := resumed.Info().StopReason; got != store.StopAgentCrashed {
t.Fatalf("resumed stop reason = %q, want %q", got, store.StopAgentCrashed)
}
- if got := resumed.Info().StopDetail; got != "daemon crashed while session active" {
- t.Fatalf("resumed stop detail = %q, want %q", got, "daemon crashed while session active")
+ if got := resumed.Info().StopDetail; got != resumeStopDetailAgentCrashed {
+ t.Fatalf("resumed stop detail = %q, want %q", got, resumeStopDetailAgentCrashed)
}
repaired := readMeta(t, resumed.MetaPath())
@@ -441,8 +442,8 @@ func TestResumePreservesCrashStopClassificationFromRepairedMetadata(t *testing.T
if *repaired.StopReason != store.StopAgentCrashed {
t.Fatalf("meta.StopReason = %q, want %q", *repaired.StopReason, store.StopAgentCrashed)
}
- if got := repaired.StopDetail; got != "daemon crashed while session active" {
- t.Fatalf("meta.StopDetail = %q, want %q", got, "daemon crashed while session active")
+ if got := repaired.StopDetail; got != resumeStopDetailAgentCrashed {
+ t.Fatalf("meta.StopDetail = %q, want %q", got, resumeStopDetailAgentCrashed)
}
}
@@ -1112,7 +1113,7 @@ func TestStopWaitsForProcessDoneAfterSuccessfulDriverStop(t *testing.T) {
return nil
}
- stopCtx, cancel := context.WithTimeout(testutil.Context(t), time.Second)
+ stopCtx, cancel := context.WithTimeout(testutil.Context(t), defaultLifecycleTimeout)
defer cancel()
stopDone := make(chan error, 1)
@@ -1855,6 +1856,7 @@ type harness struct {
driver *fakeDriver
notifier *fakeNotifier
resolver *fakeWorkspaceResolver
+ environment *environment.Registry
cfg aghconfig.Config
homePaths aghconfig.HomePaths
workspace string
@@ -1881,12 +1883,17 @@ func newHarness(t *testing.T, extraOpts ...Option) *harness {
h := &harness{
driver: newFakeDriver(),
notifier: newFakeNotifier(),
+ environment: newFakeEnvironmentRegistry(t),
cfg: aghconfig.DefaultWithHome(homePaths),
homePaths: homePaths,
workspace: workspace,
workspaceID: "ws-primary",
workspaceName: "workspace",
}
+ resolvedEnvironment, err := h.cfg.ResolveEnvironment(h.cfg.Defaults.Environment)
+ if err != nil {
+ t.Fatalf("ResolveEnvironment() error = %v", err)
+ }
h.resolver = newFakeWorkspaceResolver(&workspacepkg.ResolvedWorkspace{
Workspace: workspacepkg.Workspace{
ID: h.workspaceID,
@@ -1906,6 +1913,7 @@ func newHarness(t *testing.T, extraOpts ...Option) *harness {
Prompt: "You are a coding assistant.",
},
},
+ Environment: resolvedEnvironment,
})
h.manager = newManagerWithHarness(t, h, extraOpts...)
return h
@@ -1925,6 +1933,8 @@ func newManagerWithHarness(t *testing.T, h *harness, extraOpts ...Option) *Manag
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
WithSessionIDGenerator(sequentialIDGenerator("sess")),
WithTurnIDGenerator(sequentialIDGenerator("turn")),
+ WithEnvironmentRegistry(h.environment),
+ WithEnvironmentIDGenerator(sequentialIDGenerator("env")),
}
opts = append(opts, extraOpts...)
@@ -2265,6 +2275,70 @@ func newFakeSkillRegistry() *fakeSkillRegistry {
}
}
+func newFakeEnvironmentRegistry(t *testing.T) *environment.Registry {
+ t.Helper()
+
+ registry, err := environment.NewRegistry(fakeEnvironmentProvider{})
+ if err != nil {
+ t.Fatalf("NewRegistry(fake environment) error = %v", err)
+ }
+ return registry
+}
+
+type fakeEnvironmentProvider struct{}
+
+func (fakeEnvironmentProvider) Backend() environment.Backend {
+ return environment.BackendLocal
+}
+
+func (fakeEnvironmentProvider) Prepare(
+ _ context.Context,
+ req environment.PrepareRequest,
+) (environment.Prepared, error) {
+ state := environment.SessionState{
+ EnvironmentID: req.EnvironmentID,
+ Backend: environment.BackendLocal,
+ Profile: req.Environment.Profile,
+ InstanceID: strings.TrimSpace(req.InstanceID),
+ State: "prepared",
+ RuntimeRootDir: req.LocalRootDir,
+ RuntimeAdditionalDirs: append([]string(nil), req.LocalAdditionalDirs...),
+ ProviderState: append(json.RawMessage(nil), req.ProviderState...),
+ PreparedAt: time.Now().UTC(),
+ }
+ return environment.Prepared{
+ State: state,
+ RuntimeRootDir: req.LocalRootDir,
+ RuntimeAdditionalDirs: append([]string(nil), req.LocalAdditionalDirs...),
+ Launch: environment.LaunchSpec{
+ Command: req.AgentCommand,
+ Cwd: req.LocalRootDir,
+ AdditionalDirs: append([]string(nil), req.LocalAdditionalDirs...),
+ Env: append([]string(nil), req.AgentEnv...),
+ },
+ }, nil
+}
+
+func (fakeEnvironmentProvider) SyncToRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ return environment.SyncResult{}, nil
+}
+
+func (fakeEnvironmentProvider) SyncFromRuntime(
+ context.Context,
+ environment.SessionState,
+ environment.SyncOptions,
+) (environment.SyncResult, error) {
+ return environment.SyncResult{}, nil
+}
+
+func (fakeEnvironmentProvider) Destroy(context.Context, environment.SessionState) error {
+ return nil
+}
+
func (r *fakeSkillRegistry) ForWorkspace(
_ context.Context,
resolved *workspacepkg.ResolvedWorkspace,
@@ -2430,6 +2504,7 @@ func (d *fakeDriver) Start(_ context.Context, opts acp.StartOpts) (*AgentProcess
return nil, err
}
+ proc.handle.toolHost = copied.ToolHost
proc.handle.approvePermissionFn = func(ctx context.Context, req acp.ApproveRequest) error {
if err := ctx.Err(); err != nil {
return err
diff --git a/internal/session/manager_workspace.go b/internal/session/manager_workspace.go
index b3f2e51b5..226f57738 100644
--- a/internal/session/manager_workspace.go
+++ b/internal/session/manager_workspace.go
@@ -99,3 +99,13 @@ func resolveWorkspaceAgent(
return aghconfig.AgentDef{}, fmt.Errorf("%w: %s", workspacepkg.ErrAgentNotAvailable, target)
}
+
+func (m *Manager) resolveWorkspaceAgent(
+ agentName string,
+ resolvedWorkspace *workspacepkg.ResolvedWorkspace,
+) (aghconfig.AgentDef, error) {
+ if m != nil && m.agentResolver != nil {
+ return m.agentResolver.ResolveAgent(agentName, resolvedWorkspace)
+ }
+ return resolveWorkspaceAgent(agentName, resolvedWorkspace)
+}
diff --git a/internal/session/notifier.go b/internal/session/notifier.go
new file mode 100644
index 000000000..e183a207a
--- /dev/null
+++ b/internal/session/notifier.go
@@ -0,0 +1,16 @@
+package session
+
+import "context"
+
+func (m *Manager) notifyAgentEvent(ctx context.Context, session *Session, event any) {
+ if m == nil || m.notifier == nil || session == nil {
+ return
+ }
+
+ if aware, ok := m.notifier.(AgentEventNotifier); ok {
+ aware.OnAgentEventForSession(ctx, session, event)
+ return
+ }
+
+ m.notifier.OnAgentEvent(ctx, session.ID, event)
+}
diff --git a/internal/session/query.go b/internal/session/query.go
index 94ba4bfa6..b852363a7 100644
--- a/internal/session/query.go
+++ b/internal/session/query.go
@@ -248,6 +248,7 @@ func sessionInfoFromMeta(meta store.SessionMeta) *Info {
StopReason: sessionMetaStopReason(meta),
StopDetail: meta.StopDetail,
ACPSessionID: stringValue(meta.ACPSessionID),
+ Environment: cloneSessionEnvironmentMeta(meta.Environment),
CreatedAt: meta.CreatedAt,
UpdatedAt: meta.UpdatedAt,
}
diff --git a/internal/session/query_test.go b/internal/session/query_test.go
index cad86d78c..fa091be84 100644
--- a/internal/session/query_test.go
+++ b/internal/session/query_test.go
@@ -197,8 +197,8 @@ func TestManagerStatusRepairsIncompleteStartMetadata(t *testing.T) {
if got := info.StopReason; got != store.StopError {
t.Fatalf("Status(repaired).StopReason = %q, want %q", got, store.StopError)
}
- if got := info.StopDetail; got != "start did not complete" {
- t.Fatalf("Status(repaired).StopDetail = %q, want %q", got, "start did not complete")
+ if got := info.StopDetail; got != resumeStopDetailStartIncomplete {
+ t.Fatalf("Status(repaired).StopDetail = %q, want %q", got, resumeStopDetailStartIncomplete)
}
if got := info.ACPSessionID; got != "" {
t.Fatalf("Status(repaired).ACPSessionID = %q, want empty", got)
@@ -214,8 +214,8 @@ func TestManagerStatusRepairsIncompleteStartMetadata(t *testing.T) {
if repairedMeta.StopReason == nil || *repairedMeta.StopReason != store.StopError {
t.Fatalf("repaired meta stop reason = %#v, want %q", repairedMeta.StopReason, store.StopError)
}
- if got := repairedMeta.StopDetail; got != "start did not complete" {
- t.Fatalf("repaired meta stop detail = %q, want %q", got, "start did not complete")
+ if got := repairedMeta.StopDetail; got != resumeStopDetailStartIncomplete {
+ t.Fatalf("repaired meta stop detail = %q, want %q", got, resumeStopDetailStartIncomplete)
}
if repairedMeta.ACPSessionID != nil {
t.Fatalf("repaired meta ACPSessionID = %#v, want nil", repairedMeta.ACPSessionID)
diff --git a/internal/session/resume_repair.go b/internal/session/resume_repair.go
index 710e2e29e..4f8844923 100644
--- a/internal/session/resume_repair.go
+++ b/internal/session/resume_repair.go
@@ -18,6 +18,8 @@ const (
resumeValidationCheckWorkspace = "workspace_dir"
resumeValidationCheckAgent = "agent"
resumeValidationCheckEventStore = "event_store"
+ resumeStopDetailAgentCrashed = "daemon crashed while session active"
+ resumeStopDetailStartIncomplete = "start did not complete"
)
type resumeValidationError struct {
@@ -47,7 +49,7 @@ func classifyPreviousStop(meta store.SessionMeta) (store.SessionMeta, bool) {
case string(StateActive):
next.State = string(StateStopped)
next.StopReason = resumeStopReasonPointer(store.StopAgentCrashed)
- next.StopDetail = "daemon crashed while session active"
+ next.StopDetail = resumeStopDetailAgentCrashed
return next, true
case string(StateStopping):
next.State = string(StateStopped)
@@ -57,11 +59,11 @@ func classifyPreviousStop(meta store.SessionMeta) (store.SessionMeta, bool) {
case string(StateStarting):
next.State = string(StateStopped)
next.StopReason = resumeStopReasonPointer(store.StopError)
- next.StopDetail = "start did not complete"
+ next.StopDetail = resumeStopDetailStartIncomplete
next.ACPSessionID = nil
return next, true
case string(StateStopped):
- if strings.TrimSpace(meta.StopDetail) == "start did not complete" && meta.ACPSessionID != nil {
+ if strings.TrimSpace(meta.StopDetail) == resumeStopDetailStartIncomplete && meta.ACPSessionID != nil {
next.ACPSessionID = nil
return next, true
}
@@ -88,7 +90,7 @@ func (m *Manager) restoreFailedResumeStart(
restored.State = string(StateStopped)
if clearACP {
restored.StopReason = resumeStopReasonPointer(store.StopError)
- restored.StopDetail = "start did not complete"
+ restored.StopDetail = resumeStopDetailStartIncomplete
restored.ACPSessionID = nil
}
restored.UpdatedAt = m.now()
@@ -145,7 +147,7 @@ func (m *Manager) validateInfrastructure(ctx context.Context, meta store.Session
})
}
- if agentErr := validateResumeAgent(meta.AgentName, &resolvedWorkspace); agentErr != nil {
+ if agentErr := m.validateResumeAgent(meta.AgentName, &resolvedWorkspace); agentErr != nil {
errs = append(errs, resumeValidationError{
check: resumeValidationCheckAgent,
err: fmt.Errorf(
@@ -185,8 +187,8 @@ func validateWorkspaceRoot(path string) error {
return nil
}
-func validateResumeAgent(agentName string, resolvedWorkspace *workspacepkg.ResolvedWorkspace) error {
- agentDef, err := resolveWorkspaceAgent(agentName, resolvedWorkspace)
+func (m *Manager) validateResumeAgent(agentName string, resolvedWorkspace *workspacepkg.ResolvedWorkspace) error {
+ agentDef, err := m.resolveWorkspaceAgent(agentName, resolvedWorkspace)
if err != nil {
return err
}
diff --git a/internal/session/resume_repair_test.go b/internal/session/resume_repair_test.go
index 3b9a19d7a..b6522c86b 100644
--- a/internal/session/resume_repair_test.go
+++ b/internal/session/resume_repair_test.go
@@ -32,7 +32,7 @@ func TestClassifyPreviousStop(t *testing.T) {
wantChanged: true,
wantState: string(StateStopped),
wantReason: stopReasonPointer(store.StopAgentCrashed),
- wantDetail: "daemon crashed while session active",
+ wantDetail: resumeStopDetailAgentCrashed,
},
{
name: "stopping session classified as crashed",
@@ -48,7 +48,7 @@ func TestClassifyPreviousStop(t *testing.T) {
wantChanged: true,
wantState: string(StateStopped),
wantReason: stopReasonPointer(store.StopError),
- wantDetail: "start did not complete",
+ wantDetail: resumeStopDetailStartIncomplete,
wantACP: nil,
},
{
@@ -76,13 +76,13 @@ func TestClassifyPreviousStop(t *testing.T) {
meta: store.SessionMeta{
State: string(StateStopped),
StopReason: stopReasonPointer(store.StopError),
- StopDetail: "start did not complete",
+ StopDetail: resumeStopDetailStartIncomplete,
ACPSessionID: stringPointer("acp-stale"),
},
wantChanged: true,
wantState: string(StateStopped),
wantReason: stopReasonPointer(store.StopError),
- wantDetail: "start did not complete",
+ wantDetail: resumeStopDetailStartIncomplete,
wantACP: nil,
},
}
diff --git a/internal/session/session.go b/internal/session/session.go
index e073f8108..0ae43b47d 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -55,6 +55,7 @@ type Info struct {
StopDetail string
ACPSessionID string
ACPCaps acp.Caps
+ Environment *store.SessionEnvironmentMeta
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -76,6 +77,7 @@ type Session struct {
stopDetail string
ACPSessionID string
ACPCaps acp.Caps
+ Environment *store.SessionEnvironmentMeta
CreatedAt time.Time
UpdatedAt time.Time
@@ -85,9 +87,10 @@ type Session struct {
recorder EventRecorder
process *AgentProcess
- promptSetupCount int
- promptSetupDone chan struct{}
- currentTurnSource TurnSource
+ environmentDestroyOnStop bool
+ promptSetupCount int
+ promptSetupDone chan struct{}
+ currentTurnSource TurnSource
}
// Info returns a consistent snapshot of the current session state.
@@ -112,6 +115,7 @@ func (s *Session) Info() *Info {
StopDetail: s.stopDetail,
ACPSessionID: s.ACPSessionID,
ACPCaps: cloneCaps(s.ACPCaps),
+ Environment: cloneSessionEnvironmentMeta(s.Environment),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
@@ -417,6 +421,29 @@ func (s *Session) setStopClassification(reason store.StopReason, detail string)
s.stopDetail = strings.TrimSpace(detail)
}
+func (s *Session) setEnvironment(environment *store.SessionEnvironmentMeta, now time.Time) {
+ if s == nil {
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.Environment = cloneSessionEnvironmentMeta(environment)
+ if !now.IsZero() {
+ s.UpdatedAt = now
+ }
+}
+
+func (s *Session) environmentShouldDestroy() bool {
+ if s == nil {
+ return false
+ }
+
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.environmentDestroyOnStop
+}
+
func (s *Session) activate(now time.Time, preserveStopReason bool) error {
if err := s.transition(StateActive, now); err != nil {
return err
@@ -512,6 +539,7 @@ func (s *Session) Meta() store.SessionMeta {
StopReason: stopReasonPointer(s.stopReason),
StopDetail: s.stopDetail,
ACPSessionID: stringPointer(s.ACPSessionID),
+ Environment: cloneSessionEnvironmentMeta(s.Environment),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
diff --git a/internal/session/session_test.go b/internal/session/session_test.go
index 647e1c51a..20be5946d 100644
--- a/internal/session/session_test.go
+++ b/internal/session/session_test.go
@@ -111,7 +111,7 @@ func TestSessionActivateCanPreserveRecoveredStopClassification(t *testing.T) {
Workspace: t.TempDir(),
State: StateStarting,
stopReason: store.StopAgentCrashed,
- stopDetail: "daemon crashed while session active",
+ stopDetail: resumeStopDetailAgentCrashed,
CreatedAt: now,
UpdatedAt: now,
}
@@ -127,8 +127,8 @@ func TestSessionActivateCanPreserveRecoveredStopClassification(t *testing.T) {
if got := info.StopReason; got != store.StopAgentCrashed {
t.Fatalf("StopReason = %q, want %q", got, store.StopAgentCrashed)
}
- if got := info.StopDetail; got != "daemon crashed while session active" {
- t.Fatalf("StopDetail = %q, want %q", got, "daemon crashed while session active")
+ if got := info.StopDetail; got != resumeStopDetailAgentCrashed {
+ t.Fatalf("StopDetail = %q, want %q", got, resumeStopDetailAgentCrashed)
}
}
diff --git a/internal/skills/loader.go b/internal/skills/loader.go
index 5e45dda4b..db5829529 100644
--- a/internal/skills/loader.go
+++ b/internal/skills/loader.go
@@ -59,6 +59,9 @@ func ParseSkillFileWithSource(path string, source SkillSource) (*Skill, error) {
if err != nil {
return nil, err
}
+ if err := mergeSkillMCPSidecarFile(filepath.Dir(absPath), skill); err != nil {
+ return nil, fmt.Errorf("skills: parse %q MCP JSON: %w", absPath, err)
+ }
return skill, nil
}
diff --git a/internal/skills/registry.go b/internal/skills/registry.go
index 215661834..63fe02120 100644
--- a/internal/skills/registry.go
+++ b/internal/skills/registry.go
@@ -13,23 +13,29 @@ import (
"time"
"github.com/pedronauck/agh/internal/filesnap"
+ "github.com/pedronauck/agh/internal/resources"
workspacepkg "github.com/pedronauck/agh/internal/workspace"
)
-const workspaceCacheTTL = 10 * time.Minute
+const (
+ workspaceCacheTTL = 10 * time.Minute
+ skillSourceMarketplaceName = "marketplace"
+)
// Option customizes a Registry instance.
type Option func(*Registry)
// Registry manages global skills loaded at boot and lazily cached workspace skills.
type Registry struct {
- mu sync.RWMutex
- globalSkills map[string]*Skill
- externalSkills map[string]map[string]*Skill
- globalLoaded bool
- globalSnapshots map[string]filesnap.Snapshot
- workspaceDisabled map[string][]string
- wsCache map[string]*wsCache
+ mu sync.RWMutex
+ globalSkills map[string]*Skill
+ resourceAuthority bool
+ resourceRevision int64
+ resourceWorkspaces map[string]map[string]*Skill
+ globalLoaded bool
+ globalSnapshots map[string]filesnap.Snapshot
+ workspaceDisabled map[string][]string
+ wsCache map[string]*wsCache
globalVersion atomic.Int64
@@ -55,14 +61,14 @@ func WithNow(now func() time.Time) Option {
// NewRegistry constructs a Registry with the provided configuration.
func NewRegistry(cfg RegistryConfig, opts ...Option) *Registry {
registry := &Registry{
- globalSkills: make(map[string]*Skill),
- externalSkills: make(map[string]map[string]*Skill),
- globalSnapshots: make(map[string]filesnap.Snapshot),
- workspaceDisabled: make(map[string][]string),
- wsCache: make(map[string]*wsCache),
- cfg: cfg,
- logger: slog.Default(),
- now: time.Now,
+ globalSkills: make(map[string]*Skill),
+ resourceWorkspaces: make(map[string]map[string]*Skill),
+ globalSnapshots: make(map[string]filesnap.Snapshot),
+ workspaceDisabled: make(map[string][]string),
+ wsCache: make(map[string]*wsCache),
+ cfg: cfg,
+ logger: slog.Default(),
+ now: time.Now,
}
for _, opt := range opts {
@@ -112,10 +118,9 @@ func (r *Registry) Get(name string) (*Skill, bool) {
func (r *Registry) List() []*Skill {
r.mu.RLock()
globalSkills := r.globalSkills
- externalSkills := r.externalSkillSetLocked()
r.mu.RUnlock()
- return mergedSkillList(globalSkills, externalSkills)
+ return mergedSkillList(globalSkills, nil)
}
// LoadContent loads the full markdown body for one resolved skill.
@@ -144,6 +149,10 @@ func (r *Registry) ForWorkspace(ctx context.Context, resolved *workspacepkg.Reso
return nil, err
}
+ if skills, ok := r.resourceBackedWorkspaceSkills(resolved); ok {
+ return skills, nil
+ }
+
load, err := r.workspaceLoadFromResolved(ctx, resolved)
if err != nil {
return nil, err
@@ -165,10 +174,9 @@ func (r *Registry) ForWorkspace(ctx context.Context, resolved *workspacepkg.Reso
if cached := r.wsCache[cacheKey]; cached != nil && filesnap.Equal(cached.snapshots, load.snapshots) {
cached.lastAccess = now
globalSkills := r.globalSkills
- externalSkills := r.externalSkillSetLocked()
workspaceSkills := cached.skills
r.mu.Unlock()
- return mergedSkillList(mergeSkillMaps(globalSkills, externalSkills), workspaceSkills), nil
+ return mergedSkillList(globalSkills, workspaceSkills), nil
}
r.mu.Unlock()
@@ -186,10 +194,9 @@ func (r *Registry) ForWorkspace(ctx context.Context, resolved *workspacepkg.Reso
lastAccess: now,
}
globalSkills := r.globalSkills
- externalSkills := r.externalSkillSetLocked()
r.mu.Unlock()
- return mergedSkillList(mergeSkillMaps(globalSkills, externalSkills), workspaceSkills), nil
+ return mergedSkillList(globalSkills, workspaceSkills), nil
}
// SetEnabled updates the runtime enabled state for a named skill and keeps the
@@ -203,6 +210,15 @@ func (r *Registry) SetEnabled(name string, resolved *workspacepkg.ResolvedWorksp
r.mu.Lock()
defer r.mu.Unlock()
+ if r.resourceAuthority {
+ if skill := r.resourceSkillTargetLocked(trimmedName, resolved); skill != nil {
+ skill.Enabled = enabled
+ r.globalVersion.Add(1)
+ return nil
+ }
+ return fmt.Errorf("skills: skill %q not found", trimmedName)
+ }
+
if cacheKey, workspaceSkill := r.workspaceSkillTargetLocked(trimmedName, resolved); workspaceSkill != nil {
workspaceSkill.Enabled = enabled
r.workspaceDisabled[cacheKey] = setDisabledSkill(r.workspaceDisabled[cacheKey], trimmedName, enabled)
@@ -226,6 +242,9 @@ func (r *Registry) reloadGlobal(ctx context.Context) error {
if err := checkRegistryContext(ctx); err != nil {
return err
}
+ if r.usesResourceAuthority() {
+ return nil
+ }
disabledSkills := r.globalDisabledSkillsSnapshot()
loaded, snapshots, err := r.loadGlobalSkills(ctx, disabledSkills)
@@ -249,6 +268,96 @@ func (r *Registry) reloadGlobal(ctx context.Context) error {
return nil
}
+// DiscoverGlobal loads global skill definitions for resource publication without
+// making the file-system scan authoritative in the registry.
+func (r *Registry) DiscoverGlobal(ctx context.Context) ([]*Skill, map[string]filesnap.Snapshot, error) {
+ if err := checkRegistryContext(ctx); err != nil {
+ return nil, nil, err
+ }
+ disabledSkills := r.globalDisabledSkillsSnapshot()
+ loaded, snapshots, err := r.loadGlobalSkills(ctx, disabledSkills)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mergedSkillList(loaded, nil), filesnap.Clone(snapshots), nil
+}
+
+// DiscoverWorkspace loads workspace-visible skill definitions for resource publication.
+func (r *Registry) DiscoverWorkspace(
+ ctx context.Context,
+ resolved *workspacepkg.ResolvedWorkspace,
+) ([]*Skill, map[string]filesnap.Snapshot, error) {
+ if err := checkRegistryContext(ctx); err != nil {
+ return nil, nil, err
+ }
+ load, err := r.workspaceLoadFromResolved(ctx, resolved)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(load.paths) == 0 {
+ return nil, load.snapshots, nil
+ }
+ workspaceDisabled := r.workspaceDisabledSkillsSnapshot(
+ workspaceCacheKey(resolved, load.paths),
+ resolved.Config.Skills.DisabledSkills,
+ )
+ loaded, err := r.loadWorkspaceSkills(ctx, load.paths, workspaceDisabled)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mergedSkillList(nil, loaded), load.snapshots, nil
+}
+
+// ApplyResourceRecords atomically replaces the runtime skill catalog with the
+// canonical resource projection.
+func (r *Registry) ApplyResourceRecords(revision int64, records []resources.Record[SkillResourceSpec]) error {
+ if r == nil {
+ return errors.New("skills: registry is required")
+ }
+ globalSkills := make(map[string]*Skill)
+ workspaceSkills := make(map[string]map[string]*Skill)
+
+ ordered := append([]resources.Record[SkillResourceSpec](nil), records...)
+ slices.SortFunc(ordered, func(left, right resources.Record[SkillResourceSpec]) int {
+ return strings.Compare(skillRecordSortKey(left), skillRecordSortKey(right))
+ })
+
+ for _, record := range ordered {
+ skill, err := SkillFromResourceSpec(record.Spec)
+ if err != nil {
+ return fmt.Errorf("skills: convert resource %q: %w", record.ID, err)
+ }
+ name := strings.TrimSpace(skill.Meta.Name)
+ if name == "" {
+ continue
+ }
+ switch record.Scope.Kind.Normalize() {
+ case resources.ResourceScopeKindGlobal:
+ globalSkills[name] = skill
+ case resources.ResourceScopeKindWorkspace:
+ workspaceID := strings.TrimSpace(record.Scope.ID)
+ if workspaceID == "" {
+ continue
+ }
+ if workspaceSkills[workspaceID] == nil {
+ workspaceSkills[workspaceID] = make(map[string]*Skill)
+ }
+ workspaceSkills[workspaceID][name] = skill
+ }
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.resourceAuthority = true
+ r.resourceRevision = revision
+ r.resourceWorkspaces = workspaceSkills
+ r.globalSkills = globalSkills
+ r.wsCache = make(map[string]*wsCache)
+ r.globalLoaded = true
+ r.globalVersion.Add(1)
+ return nil
+}
+
func (r *Registry) loadGlobalSkills(
ctx context.Context,
disabledSkills []string,
@@ -524,6 +633,65 @@ func (r *Registry) globalDisabledSkillsSnapshot() []string {
return slices.Clone(r.cfg.DisabledSkills)
}
+func (r *Registry) usesResourceAuthority() bool {
+ if r == nil {
+ return false
+ }
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.resourceAuthority
+}
+
+func (r *Registry) resourceBackedWorkspaceSkills(resolved *workspacepkg.ResolvedWorkspace) ([]*Skill, bool) {
+ if r == nil {
+ return nil, false
+ }
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if !r.resourceAuthority {
+ return nil, false
+ }
+ workspaceSkills := r.resourceWorkspaces[resourceWorkspaceKey(resolved)]
+ return mergedSkillList(r.globalSkills, workspaceSkills), true
+}
+
+func (r *Registry) resourceSkillTargetLocked(name string, resolved *workspacepkg.ResolvedWorkspace) *Skill {
+ if r == nil || !r.resourceAuthority {
+ return nil
+ }
+ if key := resourceWorkspaceKey(resolved); key != "" {
+ if workspaceSkills := r.resourceWorkspaces[key]; workspaceSkills != nil {
+ if skill := workspaceSkills[name]; skill != nil {
+ return skill
+ }
+ }
+ }
+ return r.globalSkills[name]
+}
+
+func (r *Registry) lookupSkillLocked(name string) (*Skill, bool) {
+ if r == nil {
+ return nil, false
+ }
+ skill := r.globalSkills[strings.TrimSpace(name)]
+ return skill, skill != nil
+}
+
+func resourceWorkspaceKey(resolved *workspacepkg.ResolvedWorkspace) string {
+ if resolved == nil {
+ return ""
+ }
+ return strings.TrimSpace(resolved.ID)
+}
+
+func skillRecordSortKey(record resources.Record[SkillResourceSpec]) string {
+ return string(record.Scope.Kind.Normalize()) + "\x00" +
+ strings.TrimSpace(record.Scope.ID) + "\x00" +
+ string(record.Source.Kind.Normalize()) + "\x00" +
+ strings.TrimSpace(record.Source.ID) + "\x00" +
+ strings.TrimSpace(record.ID)
+}
+
func mergeDisabledSkills(base []string, extra []string) []string {
merged := slices.Clone(base)
for _, name := range extra {
@@ -591,7 +759,7 @@ func skillSourceName(source SkillSource) string {
case SourceBundled:
return "bundled"
case SourceMarketplace:
- return "marketplace"
+ return skillSourceMarketplaceName
case SourceUser:
return "user"
case SourceAdditional:
@@ -609,7 +777,7 @@ func skillSourceFromWorkspacePath(source string) (SkillSource, bool, error) {
return SourceWorkspace, true, nil
case "additional":
return SourceAdditional, true, nil
- case "marketplace":
+ case skillSourceMarketplaceName:
return SourceMarketplace, false, nil
case "global":
return SourceUser, false, nil
diff --git a/internal/skills/registry_external.go b/internal/skills/registry_external.go
deleted file mode 100644
index 645861d00..000000000
--- a/internal/skills/registry_external.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package skills
-
-import (
- "errors"
- "maps"
- "slices"
- "strings"
-)
-
-// RegisterExternal replaces the external skill set for one owner key. The
-// owner should be stable for the lifetime of the external source, such as an
-// extension name.
-func (r *Registry) RegisterExternal(owner string, skills []*Skill) error {
- if r == nil {
- return errors.New("skills: registry is required")
- }
-
- trimmedOwner := strings.TrimSpace(owner)
- if trimmedOwner == "" {
- return errors.New("skills: external owner is required")
- }
-
- registered := make(map[string]*Skill)
- for _, skill := range skills {
- if skill == nil {
- continue
- }
- name := strings.TrimSpace(skill.Meta.Name)
- if name == "" {
- continue
- }
- cloned := cloneSkill(skill)
- cloned.Meta.Name = name
- registered[name] = cloned
- }
-
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.externalSkills == nil {
- r.externalSkills = make(map[string]map[string]*Skill)
- }
-
- if len(registered) == 0 {
- delete(r.externalSkills, trimmedOwner)
- } else {
- r.externalSkills[trimmedOwner] = registered
- }
- r.globalVersion.Add(1)
-
- return nil
-}
-
-// RemoveExternal removes all externally registered skills for one owner key.
-func (r *Registry) RemoveExternal(owner string) {
- if r == nil {
- return
- }
-
- trimmedOwner := strings.TrimSpace(owner)
- if trimmedOwner == "" {
- return
- }
-
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.externalSkills == nil {
- return
- }
- delete(r.externalSkills, trimmedOwner)
- r.globalVersion.Add(1)
-}
-
-func (r *Registry) lookupSkillLocked(name string) (*Skill, bool) {
- if r == nil {
- return nil, false
- }
-
- if external := r.externalSkillSetLocked(); external != nil {
- if skill := external[name]; skill != nil {
- return skill, true
- }
- }
-
- skill, ok := r.globalSkills[name]
- return skill, ok
-}
-
-func (r *Registry) externalSkillSetLocked() map[string]*Skill {
- if r == nil || len(r.externalSkills) == 0 {
- return nil
- }
-
- owners := make([]string, 0, len(r.externalSkills))
- for owner := range r.externalSkills {
- owners = append(owners, owner)
- }
- slices.Sort(owners)
-
- merged := make(map[string]*Skill)
- for _, owner := range owners {
- maps.Copy(merged, r.externalSkills[owner])
- }
- return merged
-}
-
-func mergeSkillMaps(base map[string]*Skill, overlay map[string]*Skill) map[string]*Skill {
- switch {
- case len(base) == 0 && len(overlay) == 0:
- return nil
- case len(overlay) == 0:
- return base
- case len(base) == 0:
- return overlay
- }
-
- merged := make(map[string]*Skill, len(base)+len(overlay))
- maps.Copy(merged, base)
- maps.Copy(merged, overlay)
- return merged
-}
diff --git a/internal/skills/registry_test.go b/internal/skills/registry_test.go
index 98dca478e..dc923d2aa 100644
--- a/internal/skills/registry_test.go
+++ b/internal/skills/registry_test.go
@@ -306,36 +306,6 @@ func TestRegistryWorkspaceSkillOverridesGlobalSkill(t *testing.T) {
}
}
-func TestRegistryRegisterExternalNormalizesStoredSkillName(t *testing.T) {
- t.Parallel()
-
- registry := newTestRegistry(t, RegistryConfig{})
- input := &Skill{
- Meta: SkillMeta{Name: " external-review ", Description: "External override"},
- Source: SourceWorkspace,
- FilePath: "/tmp/external-review/SKILL.md",
- Enabled: true,
- }
-
- if err := registry.RegisterExternal("extension-owner", []*Skill{input}); err != nil {
- t.Fatalf("RegisterExternal() error = %v", err)
- }
-
- skill, ok := registry.Get("external-review")
- if !ok {
- t.Fatal("Get(external-review) ok = false, want registered external skill")
- }
- if skill.Meta.Name != "external-review" {
- t.Fatalf("Get(external-review).Meta.Name = %q, want %q", skill.Meta.Name, "external-review")
- }
- if input.Meta.Name != " external-review " {
- t.Fatalf(
- "input.Meta.Name mutated to %q, want original whitespace-preserving value",
- input.Meta.Name,
- )
- }
-}
-
func TestRegistryForWorkspaceReturnsCachedResultWhenUnchanged(t *testing.T) {
t.Parallel()
diff --git a/internal/skills/resource.go b/internal/skills/resource.go
new file mode 100644
index 000000000..9d239ece3
--- /dev/null
+++ b/internal/skills/resource.go
@@ -0,0 +1,226 @@
+package skills
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ // SkillResourceKind is the canonical desired-state resource kind for skill definitions.
+ SkillResourceKind resources.ResourceKind = "skill"
+ skillResourceMaxBytes = 512 << 10
+)
+
+// SkillResourceSpec is the resource-backed metadata index for one parsed skill.
+//
+// The full SKILL.md body intentionally remains outside the resource record and
+// is loaded through the skills package when callers need content.
+type SkillResourceSpec struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Version string `json:"version,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Source string `json:"source"`
+ Dir string `json:"dir,omitempty"`
+ FilePath string `json:"file_path,omitempty"`
+ Enabled bool `json:"enabled"`
+ MCPServers []MCPServerDecl `json:"mcp_servers,omitempty"`
+ Hooks []hookspkg.HookDecl `json:"hooks,omitempty"`
+ Provenance *Provenance `json:"provenance,omitempty"`
+ InstalledFrom string `json:"installed_from,omitempty"`
+}
+
+// NewResourceCodec builds the canonical skill resource codec.
+func NewResourceCodec() (resources.KindCodec[SkillResourceSpec], error) {
+ return resources.NewJSONCodec(SkillResourceKind, skillResourceMaxBytes, validateSkillResourceSpec)
+}
+
+// SkillToResourceSpec converts a parsed skill into its canonical resource spec.
+func SkillToResourceSpec(skill *Skill) SkillResourceSpec {
+ if skill == nil {
+ return SkillResourceSpec{}
+ }
+ return SkillResourceSpec{
+ Name: strings.TrimSpace(skill.Meta.Name),
+ Description: strings.TrimSpace(skill.Meta.Description),
+ Version: strings.TrimSpace(skill.Meta.Version),
+ Metadata: cloneMetadataMap(skill.Meta.Metadata),
+ Source: skillSourceName(skill.Source),
+ Dir: strings.TrimSpace(skill.Dir),
+ FilePath: strings.TrimSpace(skill.FilePath),
+ Enabled: skill.Enabled,
+ MCPServers: cloneMCPServerDecls(skill.MCPServers),
+ Hooks: cloneSkillHookDecls(skill.Hooks),
+ Provenance: cloneProvenance(skill.Provenance),
+ InstalledFrom: strings.TrimSpace(skill.InstalledFrom),
+ }
+}
+
+// SkillFromResourceSpec converts a canonical resource spec into the runtime skill shape.
+func SkillFromResourceSpec(spec SkillResourceSpec) (*Skill, error) {
+ source, err := skillSourceFromName(spec.Source)
+ if err != nil {
+ return nil, err
+ }
+ skill := &Skill{
+ Meta: SkillMeta{
+ Name: strings.TrimSpace(spec.Name),
+ Description: strings.TrimSpace(spec.Description),
+ Version: strings.TrimSpace(spec.Version),
+ Metadata: cloneMetadataMap(spec.Metadata),
+ },
+ Source: source,
+ Dir: strings.TrimSpace(spec.Dir),
+ FilePath: strings.TrimSpace(spec.FilePath),
+ Enabled: spec.Enabled,
+ MCPServers: cloneMCPServerDecls(spec.MCPServers),
+ Hooks: cloneSkillHookDecls(spec.Hooks),
+ Provenance: cloneProvenance(spec.Provenance),
+ InstalledFrom: strings.TrimSpace(spec.InstalledFrom),
+ }
+ refreshSkillHookDecls(skill)
+ return skill, nil
+}
+
+func validateSkillResourceSpec(
+ _ context.Context,
+ scope resources.ResourceScope,
+ spec SkillResourceSpec,
+) (SkillResourceSpec, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return SkillResourceSpec{}, err
+ }
+
+ normalized := SkillResourceSpec{
+ Name: strings.TrimSpace(spec.Name),
+ Description: strings.TrimSpace(spec.Description),
+ Version: strings.TrimSpace(spec.Version),
+ Metadata: cloneMetadataMap(spec.Metadata),
+ Source: strings.TrimSpace(spec.Source),
+ Dir: strings.TrimSpace(spec.Dir),
+ FilePath: strings.TrimSpace(spec.FilePath),
+ Enabled: spec.Enabled,
+ MCPServers: cloneMCPServerDecls(spec.MCPServers),
+ Hooks: cloneSkillHookDecls(spec.Hooks),
+ Provenance: cloneProvenance(spec.Provenance),
+ InstalledFrom: strings.TrimSpace(spec.InstalledFrom),
+ }
+ if normalized.Name == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.name is required", resources.ErrValidation)
+ }
+ if normalized.Description == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.description is required", resources.ErrValidation)
+ }
+ if _, err := skillSourceFromName(normalized.Source); err != nil {
+ return SkillResourceSpec{}, fmt.Errorf("%w: %v", resources.ErrValidation, err)
+ }
+ for idx, server := range normalized.MCPServers {
+ normalized.MCPServers[idx] = normalizeMCPServerDecl(server)
+ if strings.TrimSpace(normalized.MCPServers[idx].Name) == "" {
+ return SkillResourceSpec{}, fmt.Errorf(
+ "%w: skill.mcp_servers[%d].name is required",
+ resources.ErrValidation,
+ idx,
+ )
+ }
+ if strings.TrimSpace(normalized.MCPServers[idx].Command) == "" {
+ return SkillResourceSpec{}, fmt.Errorf(
+ "%w: skill.mcp_servers[%d].command is required",
+ resources.ErrValidation,
+ idx,
+ )
+ }
+ }
+ for idx, hook := range normalized.Hooks {
+ if err := hookspkg.ValidateHookDecl(hook); err != nil {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.hooks[%d]: %v", resources.ErrValidation, idx, err)
+ }
+ }
+ if normalized.Provenance != nil {
+ if strings.TrimSpace(normalized.Provenance.Hash) == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.provenance.hash is required", resources.ErrValidation)
+ }
+ if strings.TrimSpace(normalized.Provenance.Registry) == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.provenance.registry is required", resources.ErrValidation)
+ }
+ if strings.TrimSpace(normalized.Provenance.Slug) == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.provenance.slug is required", resources.ErrValidation)
+ }
+ if strings.TrimSpace(normalized.Provenance.Version) == "" {
+ return SkillResourceSpec{}, fmt.Errorf("%w: skill.provenance.version is required", resources.ErrValidation)
+ }
+ if normalized.Provenance.InstalledAt.IsZero() {
+ normalized.Provenance.InstalledAt = time.Time{}
+ }
+ }
+
+ return normalized, nil
+}
+
+func normalizeMCPServerDecl(decl MCPServerDecl) MCPServerDecl {
+ normalized := MCPServerDecl{
+ Name: strings.TrimSpace(decl.Name),
+ Command: strings.TrimSpace(decl.Command),
+ Args: append([]string(nil), decl.Args...),
+ Env: cloneStringMap(decl.Env),
+ }
+ for idx := range normalized.Args {
+ normalized.Args[idx] = strings.TrimSpace(normalized.Args[idx])
+ }
+ if len(normalized.Env) > 0 {
+ for key, value := range normalized.Env {
+ trimmedKey := strings.TrimSpace(key)
+ delete(normalized.Env, key)
+ if trimmedKey == "" {
+ continue
+ }
+ normalized.Env[trimmedKey] = strings.TrimSpace(value)
+ }
+ if len(normalized.Env) == 0 {
+ normalized.Env = nil
+ }
+ }
+ return normalized
+}
+
+func skillSourceFromName(source string) (SkillSource, error) {
+ switch strings.TrimSpace(source) {
+ case skillSourceName(SourceBundled):
+ return SourceBundled, nil
+ case skillSourceName(SourceMarketplace):
+ return SourceMarketplace, nil
+ case skillSourceName(SourceUser):
+ return SourceUser, nil
+ case skillSourceName(SourceAdditional):
+ return SourceAdditional, nil
+ case skillSourceName(SourceWorkspace):
+ return SourceWorkspace, nil
+ default:
+ return 0, fmt.Errorf("skills: unsupported skill source %q", source)
+ }
+}
+
+func cloneSkillHookDecls(src []hookspkg.HookDecl) []hookspkg.HookDecl {
+ if len(src) == 0 {
+ return nil
+ }
+ cloned := make([]hookspkg.HookDecl, 0, len(src))
+ for _, decl := range src {
+ next := decl
+ next.Args = append([]string(nil), decl.Args...)
+ next.Env = cloneStringMap(decl.Env)
+ next.Metadata = cloneStringMap(decl.Metadata)
+ if decl.Matcher.ToolReadOnly != nil {
+ value := *decl.Matcher.ToolReadOnly
+ next.Matcher.ToolReadOnly = &value
+ }
+ cloned = append(cloned, next)
+ }
+ return cloned
+}
diff --git a/internal/skills/resource_test.go b/internal/skills/resource_test.go
new file mode 100644
index 000000000..8f93a89c3
--- /dev/null
+++ b/internal/skills/resource_test.go
@@ -0,0 +1,450 @@
+package skills
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
+ "github.com/pedronauck/agh/internal/resources"
+ workspacepkg "github.com/pedronauck/agh/internal/workspace"
+)
+
+func TestSkillResourceCodecRejectsInvalidSpecs(t *testing.T) {
+ t.Parallel()
+
+ codec, err := NewResourceCodec()
+ if err != nil {
+ t.Fatalf("NewResourceCodec() error = %v", err)
+ }
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+
+ tests := []struct {
+ name string
+ spec SkillResourceSpec
+ wantErr string
+ }{
+ {
+ name: "missing name",
+ spec: SkillResourceSpec{
+ Description: "desc",
+ Source: "user",
+ Enabled: true,
+ },
+ wantErr: "skill.name is required",
+ },
+ {
+ name: "missing description",
+ spec: SkillResourceSpec{
+ Name: "review",
+ Source: "user",
+ Enabled: true,
+ },
+ wantErr: "skill.description is required",
+ },
+ {
+ name: "invalid source",
+ spec: SkillResourceSpec{
+ Name: "review",
+ Description: "desc",
+ Source: "elsewhere",
+ Enabled: true,
+ },
+ wantErr: "unsupported skill source",
+ },
+ {
+ name: "invalid mcp",
+ spec: SkillResourceSpec{
+ Name: "review",
+ Description: "desc",
+ Source: "user",
+ Enabled: true,
+ MCPServers: []MCPServerDecl{{
+ Name: "github",
+ }},
+ },
+ wantErr: "skill.mcp_servers[0].command",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ raw, err := codec.Encode(tt.spec)
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+ _, err = codec.DecodeAndValidate(context.Background(), scope, raw)
+ if err == nil {
+ t.Fatal("DecodeAndValidate() error = nil, want validation error")
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("DecodeAndValidate() error = %v, want %q", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestSkillResourceCodecPreservesProvenanceAndSidecarMCP(t *testing.T) {
+ t.Parallel()
+
+ skillDir := filepath.Join(t.TempDir(), "market-skill")
+ skillPath := writeSkillFile(t, skillDir, skillFileName, strings.Join([]string{
+ "---",
+ "name: market-skill",
+ "description: Installed marketplace skill",
+ "version: 1.2.3",
+ "---",
+ "Use this skill.",
+ }, "\n"))
+ writeTestFile(t, filepath.Join(skillDir, "mcp.json"), `{
+ "mcpServers": {
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-github"],
+ "env": {"GITHUB_TOKEN": "token"}
+ }
+ }
+}`)
+ if err := WriteSidecar(skillDir, Provenance{
+ Hash: skillDirectoryHash(t, skillDir),
+ Registry: "clawhub",
+ Slug: "@author/market-skill",
+ Version: "1.2.3",
+ InstalledAt: time.Date(2026, 4, 16, 1, 0, 0, 0, time.UTC),
+ }); err != nil {
+ t.Fatalf("WriteSidecar() error = %v", err)
+ }
+
+ registry := NewRegistry(RegistryConfig{UserSkillsDir: filepath.Dir(skillDir)})
+ discovered, _, err := registry.DiscoverGlobal(context.Background())
+ if err != nil {
+ t.Fatalf("DiscoverGlobal() error = %v", err)
+ }
+ if got, want := len(discovered), 1; got != want {
+ t.Fatalf("len(DiscoverGlobal()) = %d, want %d", got, want)
+ }
+ if discovered[0].FilePath != skillPath {
+ t.Fatalf("FilePath = %q, want %q", discovered[0].FilePath, skillPath)
+ }
+
+ codec, err := NewResourceCodec()
+ if err != nil {
+ t.Fatalf("NewResourceCodec() error = %v", err)
+ }
+ raw, err := codec.Encode(SkillToResourceSpec(discovered[0]))
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+ decoded, err := codec.DecodeAndValidate(
+ context.Background(),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ raw,
+ )
+ if err != nil {
+ t.Fatalf("DecodeAndValidate() error = %v", err)
+ }
+ projected, err := SkillFromResourceSpec(decoded)
+ if err != nil {
+ t.Fatalf("SkillFromResourceSpec() error = %v", err)
+ }
+ if projected.Provenance == nil || projected.Provenance.Slug != "@author/market-skill" {
+ t.Fatalf("Provenance = %#v, want marketplace sidecar provenance", projected.Provenance)
+ }
+ if got, want := projected.Source, SourceMarketplace; got != want {
+ t.Fatalf("Source = %v, want %v", got, want)
+ }
+ if got, want := len(projected.MCPServers), 1; got != want {
+ t.Fatalf("len(MCPServers) = %d, want %d", got, want)
+ }
+ if got, want := projected.MCPServers[0].Command, "npx"; got != want {
+ t.Fatalf("MCP command = %q, want %q", got, want)
+ }
+}
+
+func TestParseSkillFileWithSourceMergesMCPSidecar(t *testing.T) {
+ t.Parallel()
+
+ skillDir := filepath.Join(t.TempDir(), "extension-skill")
+ skillPath := writeSkillFile(t, skillDir, skillFileName, strings.Join([]string{
+ "---",
+ "name: extension-skill",
+ "description: Extension skill",
+ "---",
+ "Use this skill.",
+ }, "\n"))
+ writeTestFile(t, filepath.Join(skillDir, "mcp.json"), `{
+ "mcpServers": {
+ "extension-mcp": {
+ "command": "extension-command"
+ }
+ }
+}`)
+
+ skill, err := ParseSkillFileWithSource(skillPath, SourceWorkspace)
+ if err != nil {
+ t.Fatalf("ParseSkillFileWithSource() error = %v", err)
+ }
+ if got, want := skill.Source, SourceWorkspace; got != want {
+ t.Fatalf("Source = %v, want %v", got, want)
+ }
+ if got, want := len(skill.MCPServers), 1; got != want {
+ t.Fatalf("len(MCPServers) = %d, want %d", got, want)
+ }
+ if got, want := skill.MCPServers[0].Name, "extension-mcp"; got != want {
+ t.Fatalf("MCPServers[0].Name = %q, want %q", got, want)
+ }
+}
+
+func TestSkillResourceCodecCanonicalizesHookMetadata(t *testing.T) {
+ t.Parallel()
+
+ toolReadOnly := true
+ codec, err := NewResourceCodec()
+ if err != nil {
+ t.Fatalf("NewResourceCodec() error = %v", err)
+ }
+ raw, err := codec.Encode(SkillResourceSpec{
+ Name: "hooked-skill",
+ Description: "Skill with hooks",
+ Source: skillSourceName(SourceWorkspace),
+ Enabled: true,
+ Hooks: []hookspkg.HookDecl{{
+ Name: "hooked",
+ Event: hookspkg.HookToolPreCall,
+ Source: hookspkg.HookSourceSkill,
+ Mode: hookspkg.HookModeSync,
+ Command: "echo",
+ Args: []string{"ok"},
+ Env: map[string]string{"ONE": "1"},
+ Matcher: hookspkg.HookMatcher{ToolReadOnly: &toolReadOnly},
+ Metadata: map[string]string{
+ "skill": "hooked-skill",
+ },
+ }},
+ })
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+
+ decoded, err := codec.DecodeAndValidate(
+ context.Background(),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindWorkspace, ID: "ws-hooks"},
+ raw,
+ )
+ if err != nil {
+ t.Fatalf("DecodeAndValidate() error = %v", err)
+ }
+ toolReadOnly = false
+ if got, want := len(decoded.Hooks), 1; got != want {
+ t.Fatalf("len(Hooks) = %d, want %d", got, want)
+ }
+ hook := decoded.Hooks[0]
+ if hook.Matcher.ToolReadOnly == nil || !*hook.Matcher.ToolReadOnly {
+ t.Fatalf("Hook matcher ToolReadOnly = %#v, want cloned true pointer", hook.Matcher.ToolReadOnly)
+ }
+ if hook.Args[0] != "ok" || hook.Env["ONE"] != "1" || hook.Metadata["skill"] != "hooked-skill" {
+ t.Fatalf("Hook clone = %#v, want args/env/metadata preserved", hook)
+ }
+}
+
+func TestResourceAuthorityKeepsFilesystemDiscoveryNonAuthoritative(t *testing.T) {
+ t.Parallel()
+
+ userDir := t.TempDir()
+ writeSkillFile(t, userDir, filepath.Join("legacy-skill", skillFileName), strings.Join([]string{
+ "---",
+ "name: legacy-skill",
+ "description: Filesystem skill",
+ "---",
+ "Loaded only before resource authority exists.",
+ }, "\n"))
+
+ registry := NewRegistry(RegistryConfig{UserSkillsDir: userDir})
+ records := []resources.Record[SkillResourceSpec]{{
+ Kind: SkillResourceKind,
+ ID: "global:resource-backed",
+ Scope: resources.ResourceScope{
+ Kind: resources.ResourceScopeKindGlobal,
+ },
+ Spec: SkillResourceSpec{
+ Name: "resource-backed",
+ Description: "Canonical resource skill",
+ Source: skillSourceName(SourceUser),
+ Enabled: true,
+ },
+ }}
+ if err := registry.ApplyResourceRecords(1, records); err != nil {
+ t.Fatalf("ApplyResourceRecords() error = %v", err)
+ }
+ if err := registry.LoadAll(context.Background()); err != nil {
+ t.Fatalf("LoadAll() error = %v", err)
+ }
+
+ if _, ok := registry.Get("legacy-skill"); ok {
+ t.Fatal("Get(\"legacy-skill\") ok = true, want filesystem discovery non-authoritative after resource cutover")
+ }
+ if _, ok := registry.Get("resource-backed"); !ok {
+ t.Fatal("Get(\"resource-backed\") ok = false, want canonical resource skill")
+ }
+}
+
+func TestResourceAuthorityProjectsWorkspaceSkills(t *testing.T) {
+ t.Parallel()
+
+ registry := NewRegistry(RegistryConfig{})
+ records := []resources.Record[SkillResourceSpec]{
+ {
+ Kind: SkillResourceKind,
+ ID: "global:global-skill",
+ Scope: resources.ResourceScope{
+ Kind: resources.ResourceScopeKindGlobal,
+ },
+ Spec: SkillResourceSpec{
+ Name: "global-skill",
+ Description: "Global resource skill",
+ Source: skillSourceName(SourceUser),
+ Enabled: true,
+ },
+ },
+ {
+ Kind: SkillResourceKind,
+ ID: "workspace:workspace-skill",
+ Scope: resources.ResourceScope{
+ Kind: resources.ResourceScopeKindWorkspace,
+ ID: "/workspace/project",
+ },
+ Spec: SkillResourceSpec{
+ Name: "workspace-skill",
+ Description: "Workspace resource skill",
+ Source: skillSourceName(SourceWorkspace),
+ Enabled: true,
+ },
+ },
+ }
+ if err := registry.ApplyResourceRecords(2, records); err != nil {
+ t.Fatalf("ApplyResourceRecords() error = %v", err)
+ }
+
+ skills, err := registry.ForWorkspace(context.Background(), &workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{ID: "/workspace/project"},
+ })
+ if err != nil {
+ t.Fatalf("ForWorkspace() error = %v", err)
+ }
+ if !hasSkillNamed(skills, "global-skill") {
+ t.Fatal("ForWorkspace() missing global-skill")
+ }
+ if !hasSkillNamed(skills, "workspace-skill") {
+ t.Fatal("ForWorkspace() missing workspace-skill")
+ }
+
+ other, err := registry.ForWorkspace(context.Background(), &workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{ID: "/workspace/other"},
+ })
+ if err != nil {
+ t.Fatalf("ForWorkspace(other) error = %v", err)
+ }
+ if hasSkillNamed(other, "workspace-skill") {
+ t.Fatal("ForWorkspace(other) includes workspace-skill, want workspace scope isolation")
+ }
+
+ if err := registry.SetEnabled(
+ "workspace-skill",
+ &workspacepkg.ResolvedWorkspace{Workspace: workspacepkg.Workspace{ID: "/workspace/project"}},
+ false,
+ ); err != nil {
+ t.Fatalf("SetEnabled(workspace-skill) error = %v", err)
+ }
+ updated, err := registry.ForWorkspace(context.Background(), &workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{ID: "/workspace/project"},
+ })
+ if err != nil {
+ t.Fatalf("ForWorkspace(updated) error = %v", err)
+ }
+ workspaceSkill := findSkillByName(updated, "workspace-skill")
+ if workspaceSkill == nil || workspaceSkill.Enabled {
+ t.Fatalf("workspace-skill after SetEnabled = %#v, want disabled resource projection", workspaceSkill)
+ }
+}
+
+func TestDiscoverWorkspaceLoadsDefinitionsForPublication(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ skillDir := filepath.Join(root, "workspace-review")
+ writeSkillFile(t, skillDir, skillFileName, strings.Join([]string{
+ "---",
+ "name: workspace-review",
+ "description: Workspace review",
+ "---",
+ "Review workspace changes.",
+ }, "\n"))
+
+ registry := NewRegistry(RegistryConfig{})
+ discovered, snapshots, err := registry.DiscoverWorkspace(
+ context.Background(),
+ &workspacepkg.ResolvedWorkspace{
+ Workspace: workspacepkg.Workspace{ID: "ws-discover"},
+ Skills: []workspacepkg.SkillPath{{
+ Dir: skillDir,
+ Source: "workspace",
+ }},
+ },
+ )
+ if err != nil {
+ t.Fatalf("DiscoverWorkspace() error = %v", err)
+ }
+ if got, want := len(discovered), 1; got != want {
+ t.Fatalf("len(DiscoverWorkspace()) = %d, want %d", got, want)
+ }
+ if discovered[0].Meta.Name != "workspace-review" || discovered[0].Source != SourceWorkspace {
+ t.Fatalf("DiscoverWorkspace()[0] = %#v, want workspace-review from workspace source", discovered[0])
+ }
+ if len(snapshots) == 0 {
+ t.Fatal("DiscoverWorkspace() snapshots = empty, want publication change tracking snapshots")
+ }
+}
+
+func findSkillByName(skills []*Skill, name string) *Skill {
+ for _, skill := range skills {
+ if skill != nil && skill.Meta.Name == name {
+ return skill
+ }
+ }
+ return nil
+}
+
+func hasSkillNamed(skills []*Skill, name string) bool {
+ for _, skill := range skills {
+ if skill != nil && skill.Meta.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func skillDirectoryHash(t *testing.T, skillDir string) string {
+ t.Helper()
+
+ hash, err := ComputeDirectoryHash(skillDir)
+ if err != nil {
+ t.Fatalf("ComputeDirectoryHash(%q) error = %v", skillDir, err)
+ }
+ return hash
+}
+
+func writeTestFile(t *testing.T, path string, content string) {
+ t.Helper()
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("WriteFile(%q) error = %v", path, err)
+ }
+}
diff --git a/internal/store/globaldb/global_db.go b/internal/store/globaldb/global_db.go
index ced5c2dfa..3fa8c0a1c 100644
--- a/internal/store/globaldb/global_db.go
+++ b/internal/store/globaldb/global_db.go
@@ -9,17 +9,19 @@ import (
"sync/atomic"
"time"
+ "github.com/pedronauck/agh/internal/resources"
"github.com/pedronauck/agh/internal/store"
aghworkspace "github.com/pedronauck/agh/internal/workspace"
)
-var globalSchemaStatements = []string{
+var globalSchemaStatements = append([]string{
`CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
root_dir TEXT NOT NULL UNIQUE,
add_dirs TEXT NOT NULL DEFAULT '[]',
name TEXT NOT NULL UNIQUE,
default_agent TEXT DEFAULT '',
+ environment_ref TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
@@ -35,6 +37,14 @@ var globalSchemaStatements = []string{
acp_session_id TEXT,
stop_reason TEXT,
stop_detail TEXT,
+ environment_id TEXT NOT NULL DEFAULT '',
+ environment_backend TEXT NOT NULL DEFAULT 'local',
+ environment_profile TEXT NOT NULL DEFAULT '',
+ environment_instance_id TEXT NOT NULL DEFAULT '',
+ environment_state TEXT NOT NULL DEFAULT '',
+ environment_provider_state_json TEXT NOT NULL DEFAULT '',
+ environment_last_sync_at TEXT,
+ environment_last_sync_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
@@ -168,27 +178,22 @@ var globalSchemaStatements = []string{
attempt INTEGER NOT NULL DEFAULT 1,
started_at TEXT,
ended_at TEXT,
- error TEXT,
- FOREIGN KEY(job_id) REFERENCES automation_jobs(id) ON DELETE SET NULL,
- FOREIGN KEY(trigger_id) REFERENCES automation_triggers(id) ON DELETE SET NULL
+ error TEXT
);`,
`CREATE TABLE IF NOT EXISTS automation_job_overlays (
job_id TEXT PRIMARY KEY,
enabled_override BOOLEAN NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(job_id) REFERENCES automation_jobs(id) ON DELETE CASCADE
+ updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS automation_trigger_overlays (
trigger_id TEXT PRIMARY KEY,
enabled_override BOOLEAN NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(trigger_id) REFERENCES automation_triggers(id) ON DELETE CASCADE
+ updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS automation_trigger_webhook_secrets (
trigger_id TEXT PRIMARY KEY,
secret TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(trigger_id) REFERENCES automation_triggers(id) ON DELETE CASCADE
+ updated_at TEXT NOT NULL
);`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_automation_jobs_global_name ON automation_jobs(name) WHERE scope = 'global';`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_automation_jobs_workspace_name ON automation_jobs(workspace_id, name) WHERE scope = 'workspace';`,
@@ -394,34 +399,7 @@ var globalSchemaStatements = []string{
expires_at TEXT NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_bridge_ingest_dedup_expires ON bridge_ingest_dedup(expires_at);`,
- `CREATE TABLE IF NOT EXISTS bundle_activations (
- id TEXT PRIMARY KEY,
- extension_name TEXT NOT NULL REFERENCES extensions(name) ON DELETE CASCADE,
- bundle_name TEXT NOT NULL,
- profile_name TEXT NOT NULL,
- scope TEXT NOT NULL CHECK (scope IN ('global', 'workspace')),
- workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
- spec_content_hash TEXT,
- bind_primary_channel_default BOOLEAN NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- CHECK (
- (scope = 'global' AND workspace_id IS NULL) OR
- (scope = 'workspace' AND workspace_id IS NOT NULL)
- )
- );`,
- `CREATE UNIQUE INDEX IF NOT EXISTS uq_bundle_activations_tuple ON bundle_activations(extension_name, bundle_name, profile_name, scope, IFNULL(workspace_id, ''));`,
- `CREATE INDEX IF NOT EXISTS idx_bundle_activations_extension ON bundle_activations(extension_name, created_at);`,
- `CREATE TABLE IF NOT EXISTS bundle_activation_inventory (
- activation_id TEXT NOT NULL REFERENCES bundle_activations(id) ON DELETE CASCADE,
- resource_kind TEXT NOT NULL,
- resource_id TEXT NOT NULL,
- resource_name TEXT NOT NULL,
- recorded_at TEXT NOT NULL,
- PRIMARY KEY (activation_id, resource_kind, resource_id)
- );`,
- `CREATE INDEX IF NOT EXISTS idx_bundle_activation_inventory_kind ON bundle_activation_inventory(resource_kind, recorded_at DESC);`,
-}
+}, resources.SchemaStatements()...)
// GlobalDB owns the global session index and observability database.
type GlobalDB struct {
diff --git a/internal/store/globaldb/global_db_automation.go b/internal/store/globaldb/global_db_automation.go
index c95d6b84b..e47087b2e 100644
--- a/internal/store/globaldb/global_db_automation.go
+++ b/internal/store/globaldb/global_db_automation.go
@@ -555,10 +555,6 @@ func (g *GlobalDB) SetJobEnabledOverlay(
if err != nil {
return automation.JobEnabledOverlay{}, err
}
- if err := g.ensureConfigJob(ctx, normalized.JobID); err != nil {
- return automation.JobEnabledOverlay{}, err
- }
-
if _, err := g.db.ExecContext(
ctx,
`INSERT INTO automation_job_overlays (job_id, enabled_override, updated_at)
@@ -674,10 +670,6 @@ func (g *GlobalDB) SetTriggerEnabledOverlay(
if err != nil {
return automation.TriggerEnabledOverlay{}, err
}
- if err := g.ensureConfigTrigger(ctx, normalized.TriggerID); err != nil {
- return automation.TriggerEnabledOverlay{}, err
- }
-
if _, err := g.db.ExecContext(
ctx,
`INSERT INTO automation_trigger_overlays (trigger_id, enabled_override, updated_at)
@@ -802,14 +794,6 @@ func (g *GlobalDB) SetTriggerWebhookSecret(ctx context.Context, triggerID string
return errors.New("store: automation trigger webhook secret is required")
}
- trigger, err := g.GetTrigger(ctx, trimmedID)
- if err != nil {
- return err
- }
- if !strings.EqualFold(strings.TrimSpace(trigger.Event), "webhook") {
- return errors.New("store: automation trigger webhook secret requires a webhook trigger")
- }
-
if _, err := g.db.ExecContext(
ctx,
`INSERT INTO automation_trigger_webhook_secrets (trigger_id, secret, updated_at)
@@ -1066,62 +1050,6 @@ func (g *GlobalDB) normalizeRunForUpdate(run automation.Run) (automation.Run, er
return normalized, nil
}
-func (g *GlobalDB) ensureConfigJob(ctx context.Context, jobID string) error {
- source, err := g.lookupJobSource(ctx, jobID)
- if err != nil {
- return err
- }
- if source != automation.JobSourceConfig {
- return automation.ErrOverlayRequiresConfigSource
- }
- return nil
-}
-
-func (g *GlobalDB) ensureConfigTrigger(ctx context.Context, triggerID string) error {
- source, err := g.lookupTriggerSource(ctx, triggerID)
- if err != nil {
- return err
- }
- if source != automation.JobSourceConfig {
- return automation.ErrOverlayRequiresConfigSource
- }
- return nil
-}
-
-func (g *GlobalDB) lookupJobSource(ctx context.Context, jobID string) (automation.JobSource, error) {
- var raw string
- if err := g.db.QueryRowContext(ctx, `SELECT source FROM automation_jobs WHERE id = ?`, jobID).
- Scan(&raw); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return "", automation.ErrJobNotFound
- }
- return "", fmt.Errorf("store: query automation job %q source: %w", jobID, err)
- }
-
- source := automation.JobSource(strings.TrimSpace(raw))
- if err := source.Validate("job.source"); err != nil {
- return "", err
- }
- return source, nil
-}
-
-func (g *GlobalDB) lookupTriggerSource(ctx context.Context, triggerID string) (automation.JobSource, error) {
- var raw string
- if err := g.db.QueryRowContext(ctx, `SELECT source FROM automation_triggers WHERE id = ?`, triggerID).
- Scan(&raw); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return "", automation.ErrTriggerNotFound
- }
- return "", fmt.Errorf("store: query automation trigger %q source: %w", triggerID, err)
- }
-
- source := automation.JobSource(strings.TrimSpace(raw))
- if err := source.Validate("trigger.source"); err != nil {
- return "", err
- }
- return source, nil
-}
-
func scanAutomationJob(scanner rowScanner) (automation.Job, error) {
var (
job automation.Job
diff --git a/internal/store/globaldb/global_db_automation_test.go b/internal/store/globaldb/global_db_automation_test.go
index 4677724ec..e965ef436 100644
--- a/internal/store/globaldb/global_db_automation_test.go
+++ b/internal/store/globaldb/global_db_automation_test.go
@@ -314,23 +314,11 @@ func TestGlobalDBJobEnabledOverlayDoesNotMutateDefinition(t *testing.T) {
t.Fatalf("len(ListJobEnabledOverlays()) = %d, want %d", got, want)
}
- dynamicJob, err := globalDB.CreateJob(
- testutil.Context(t),
- automationJobForTest(
- automation.AutomationScopeWorkspace,
- "dynamic-job",
- workspaceID,
- automation.JobSourceDynamic,
- ),
- )
- if err != nil {
- t.Fatalf("CreateJob(dynamic) error = %v", err)
- }
if _, err := globalDB.SetJobEnabledOverlay(testutil.Context(t), JobEnabledOverlay{
- JobID: dynamicJob.ID,
+ JobID: "resource-backed-job",
EnabledOverride: false,
- }); !errors.Is(err, automation.ErrOverlayRequiresConfigSource) {
- t.Fatalf("SetJobEnabledOverlay(dynamic) error = %v, want ErrOverlayRequiresConfigSource", err)
+ }); err != nil {
+ t.Fatalf("SetJobEnabledOverlay(resource-backed) error = %v", err)
}
if err := globalDB.DeleteJobEnabledOverlay(testutil.Context(t), created.ID); err != nil {
@@ -396,23 +384,11 @@ func TestGlobalDBTriggerEnabledOverlayDoesNotMutateDefinition(t *testing.T) {
t.Fatalf("len(ListTriggerEnabledOverlays()) = %d, want %d", got, want)
}
- dynamicTriggerDef := automationWebhookTriggerForTest(
- automation.AutomationScopeWorkspace,
- "dynamic-trigger",
- workspaceID,
- automation.JobSourceDynamic,
- )
- dynamicTriggerDef.WebhookID = "wbh_dynamic-trigger-webhook"
- dynamicTriggerDef.EndpointSlug = "dynamic-trigger-endpoint"
- dynamicTrigger, err := globalDB.CreateTrigger(testutil.Context(t), dynamicTriggerDef)
- if err != nil {
- t.Fatalf("CreateTrigger(dynamic) error = %v", err)
- }
if _, err := globalDB.SetTriggerEnabledOverlay(testutil.Context(t), TriggerEnabledOverlay{
- TriggerID: dynamicTrigger.ID,
+ TriggerID: "resource-backed-trigger",
EnabledOverride: false,
- }); !errors.Is(err, automation.ErrOverlayRequiresConfigSource) {
- t.Fatalf("SetTriggerEnabledOverlay(dynamic) error = %v, want ErrOverlayRequiresConfigSource", err)
+ }); err != nil {
+ t.Fatalf("SetTriggerEnabledOverlay(resource-backed) error = %v", err)
}
if err := globalDB.DeleteTriggerEnabledOverlay(testutil.Context(t), created.ID); err != nil {
@@ -852,7 +828,7 @@ func TestAutomationStoreHelperBranches(t *testing.T) {
}
}
-func TestGlobalDBLookupAutomationSources(t *testing.T) {
+func TestGlobalDBAutomationDefinitionsExposeSourceForLegacyRows(t *testing.T) {
t.Parallel()
globalDB := openTestGlobalDB(t)
@@ -882,38 +858,38 @@ func TestGlobalDBLookupAutomationSources(t *testing.T) {
t.Fatalf("CreateTrigger() error = %v", err)
}
- jobSource, err := globalDB.lookupJobSource(testutil.Context(t), job.ID)
+ storedJob, err := globalDB.GetJob(testutil.Context(t), job.ID)
if err != nil {
- t.Fatalf("lookupJobSource() error = %v", err)
+ t.Fatalf("GetJob() error = %v", err)
}
- if got, want := jobSource, automation.JobSourceConfig; got != want {
- t.Fatalf("lookupJobSource() = %q, want %q", got, want)
+ if got, want := storedJob.Source, automation.JobSourceConfig; got != want {
+ t.Fatalf("GetJob().Source = %q, want %q", got, want)
}
- triggerSource, err := globalDB.lookupTriggerSource(testutil.Context(t), trigger.ID)
+ storedTrigger, err := globalDB.GetTrigger(testutil.Context(t), trigger.ID)
if err != nil {
- t.Fatalf("lookupTriggerSource() error = %v", err)
+ t.Fatalf("GetTrigger() error = %v", err)
}
- if got, want := triggerSource, automation.JobSourceConfig; got != want {
- t.Fatalf("lookupTriggerSource() = %q, want %q", got, want)
+ if got, want := storedTrigger.Source, automation.JobSourceConfig; got != want {
+ t.Fatalf("GetTrigger().Source = %q, want %q", got, want)
}
- if _, err := globalDB.lookupJobSource(
+ if _, err := globalDB.GetJob(
testutil.Context(t),
"missing-job",
); !errors.Is(
err,
automation.ErrJobNotFound,
) {
- t.Fatalf("lookupJobSource(missing) error = %v, want ErrJobNotFound", err)
+ t.Fatalf("GetJob(missing) error = %v, want ErrJobNotFound", err)
}
- if _, err := globalDB.lookupTriggerSource(
+ if _, err := globalDB.GetTrigger(
testutil.Context(t),
"missing-trigger",
); !errors.Is(
err,
automation.ErrTriggerNotFound,
) {
- t.Fatalf("lookupTriggerSource(missing) error = %v, want ErrTriggerNotFound", err)
+ t.Fatalf("GetTrigger(missing) error = %v, want ErrTriggerNotFound", err)
}
}
diff --git a/internal/store/globaldb/global_db_bridge.go b/internal/store/globaldb/global_db_bridge.go
index cc4fdb22f..1e25a469a 100644
--- a/internal/store/globaldb/global_db_bridge.go
+++ b/internal/store/globaldb/global_db_bridge.go
@@ -227,6 +227,157 @@ func (g *GlobalDB) ListBridgeInstances(ctx context.Context) ([]bridges.BridgeIns
return instances, nil
}
+// ReplaceBridgeInstances atomically swaps the daemon-visible bridge instance projection.
+func (g *GlobalDB) ReplaceBridgeInstances(ctx context.Context, instances []bridges.BridgeInstance) (err error) {
+ if err := g.checkReady(ctx, "replace bridge instances"); err != nil {
+ return err
+ }
+
+ prepared := make([]bridges.BridgeInstance, 0, len(instances))
+ seen := make(map[string]struct{}, len(instances))
+ for _, instance := range instances {
+ normalized,
+ _,
+ _,
+ _,
+ _,
+ _,
+ normalizeErr := normalizeBridgeInstanceRecord(instance)
+ if normalizeErr != nil {
+ return normalizeErr
+ }
+ if _, exists := seen[normalized.ID]; exists {
+ return fmt.Errorf("store: duplicate bridge instance %q in replacement set", normalized.ID)
+ }
+ seen[normalized.ID] = struct{}{}
+ prepared = append(prepared, normalized)
+ }
+
+ tx, err := g.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("store: begin bridge instance replacement transaction: %w", err)
+ }
+ defer func() {
+ if err != nil {
+ err = errors.Join(err, rollbackTx(tx, "bridge instance replacement"))
+ }
+ }()
+
+ for _, instance := range prepared {
+ if err := upsertBridgeInstance(ctx, tx, instance, g.now); err != nil {
+ return err
+ }
+ }
+ rows, err := tx.QueryContext(ctx, `SELECT id FROM bridge_instances`)
+ if err != nil {
+ return fmt.Errorf("store: query stale bridge instances during replacement: %w", err)
+ }
+ var staleIDs []string
+ for rows.Next() {
+ var id string
+ if scanErr := rows.Scan(&id); scanErr != nil {
+ closeErr := rows.Close()
+ return errors.Join(fmt.Errorf("store: scan stale bridge instance id: %w", scanErr), closeErr)
+ }
+ if _, keep := seen[id]; !keep {
+ staleIDs = append(staleIDs, id)
+ }
+ }
+ if rowsErr := rows.Err(); rowsErr != nil {
+ closeErr := rows.Close()
+ return errors.Join(fmt.Errorf("store: iterate stale bridge instance ids: %w", rowsErr), closeErr)
+ }
+ if closeErr := rows.Close(); closeErr != nil {
+ return fmt.Errorf("store: close stale bridge instance rows: %w", closeErr)
+ }
+ for _, id := range staleIDs {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM bridge_instances WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("store: delete stale bridge instance %q during replacement: %w", id, err)
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("store: commit bridge instance replacement transaction: %w", err)
+ }
+ return nil
+}
+
+func upsertBridgeInstance(
+ ctx context.Context,
+ execer interface {
+ ExecContext(context.Context, string, ...any) (sql.Result, error)
+ },
+ instance bridges.BridgeInstance,
+ now func() time.Time,
+) error {
+ normalized,
+ routingPolicyJSON,
+ providerConfig,
+ deliveryDefaults,
+ degradationReason,
+ degradationMessage,
+ err := normalizeBridgeInstanceRecord(instance)
+ if err != nil {
+ return err
+ }
+ clock := now
+ if clock == nil {
+ clock = time.Now
+ }
+ if normalized.CreatedAt.IsZero() {
+ normalized.CreatedAt = clock().UTC()
+ }
+ if normalized.UpdatedAt.IsZero() {
+ normalized.UpdatedAt = normalized.CreatedAt
+ }
+
+ if _, err := execer.ExecContext(
+ ctx,
+ `INSERT INTO bridge_instances (
+ id, scope, workspace_id, platform, extension_name, display_name,
+ source, enabled, status, dm_policy, routing_policy, provider_config,
+ delivery_defaults, degradation_reason, degradation_message,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ scope = excluded.scope,
+ workspace_id = excluded.workspace_id,
+ platform = excluded.platform,
+ extension_name = excluded.extension_name,
+ display_name = excluded.display_name,
+ source = excluded.source,
+ enabled = excluded.enabled,
+ status = excluded.status,
+ dm_policy = excluded.dm_policy,
+ routing_policy = excluded.routing_policy,
+ provider_config = excluded.provider_config,
+ delivery_defaults = excluded.delivery_defaults,
+ degradation_reason = excluded.degradation_reason,
+ degradation_message = excluded.degradation_message,
+ updated_at = excluded.updated_at`,
+ normalized.ID,
+ string(normalized.Scope),
+ store.NullableString(normalized.WorkspaceID),
+ normalized.Platform,
+ normalized.ExtensionName,
+ normalized.DisplayName,
+ string(normalized.Source),
+ normalized.Enabled,
+ string(normalized.Status),
+ string(normalized.DMPolicy),
+ routingPolicyJSON,
+ providerConfig,
+ deliveryDefaults,
+ degradationReason,
+ degradationMessage,
+ store.FormatTimestamp(normalized.CreatedAt),
+ store.FormatTimestamp(normalized.UpdatedAt),
+ ); err != nil {
+ return fmt.Errorf("store: replace bridge instance %q: %w", normalized.ID, mapBridgeInstanceConstraintError(err))
+ }
+ return nil
+}
+
// PutBridgeSecretBinding inserts or refreshes a persisted secret binding row.
func (g *GlobalDB) PutBridgeSecretBinding(ctx context.Context, binding bridges.BridgeSecretBinding) error {
if err := g.checkReady(ctx, "put bridge secret binding"); err != nil {
diff --git a/internal/store/globaldb/global_db_bridges_test.go b/internal/store/globaldb/global_db_bridges_test.go
index de0ee1a1d..0ffb62ada 100644
--- a/internal/store/globaldb/global_db_bridges_test.go
+++ b/internal/store/globaldb/global_db_bridges_test.go
@@ -304,6 +304,77 @@ func TestGlobalDBBridgePersistenceHelpers(t *testing.T) {
}
}
+func TestGlobalDBReplaceBridgeInstancesAtomicallySwapsProjection(t *testing.T) {
+ t.Parallel()
+
+ globalDB := openTestGlobalDB(t)
+ now := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC)
+ stale := bridges.BridgeInstance{
+ ID: "brg-stale",
+ Scope: bridges.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "ext-telegram",
+ DisplayName: "Stale Bridge",
+ Source: bridges.BridgeInstanceSourceDynamic,
+ Enabled: true,
+ Status: bridges.BridgeStatusReady,
+ DMPolicy: bridges.BridgeDMPolicyOpen,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now.Add(-time.Hour),
+ UpdatedAt: now.Add(-time.Hour),
+ }
+ keep := stale
+ keep.ID = "brg-keep"
+ keep.DisplayName = "Keep Bridge"
+ if err := globalDB.InsertBridgeInstance(testutil.Context(t), stale); err != nil {
+ t.Fatalf("InsertBridgeInstance(stale) error = %v", err)
+ }
+ if err := globalDB.InsertBridgeInstance(testutil.Context(t), keep); err != nil {
+ t.Fatalf("InsertBridgeInstance(keep) error = %v", err)
+ }
+
+ keep.DisplayName = "Projected Bridge"
+ keep.UpdatedAt = now
+ added := bridges.BridgeInstance{
+ ID: "brg-added",
+ Scope: bridges.ScopeGlobal,
+ Platform: "slack",
+ ExtensionName: "ext-slack",
+ DisplayName: "Added Bridge",
+ Source: bridges.BridgeInstanceSourceDynamic,
+ Enabled: false,
+ Status: bridges.BridgeStatusDisabled,
+ DMPolicy: bridges.BridgeDMPolicyPairing,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := globalDB.ReplaceBridgeInstances(testutil.Context(t), []bridges.BridgeInstance{keep, added}); err != nil {
+ t.Fatalf("ReplaceBridgeInstances() error = %v", err)
+ }
+
+ if _, err := globalDB.GetBridgeInstance(testutil.Context(t), stale.ID); !errors.Is(
+ err,
+ bridges.ErrBridgeInstanceNotFound,
+ ) {
+ t.Fatalf("GetBridgeInstance(stale) error = %v, want ErrBridgeInstanceNotFound", err)
+ }
+ loaded, err := globalDB.GetBridgeInstance(testutil.Context(t), keep.ID)
+ if err != nil {
+ t.Fatalf("GetBridgeInstance(keep) error = %v", err)
+ }
+ if got, want := loaded.DisplayName, "Projected Bridge"; got != want {
+ t.Fatalf("loaded.DisplayName = %q, want %q", got, want)
+ }
+ instances, err := globalDB.ListBridgeInstances(testutil.Context(t))
+ if err != nil {
+ t.Fatalf("ListBridgeInstances() error = %v", err)
+ }
+ if got, want := len(instances), 2; got != want {
+ t.Fatalf("len(ListBridgeInstances()) = %d, want %d", got, want)
+ }
+}
+
func TestGlobalDBBridgeRouteCRUD(t *testing.T) {
t.Parallel()
diff --git a/internal/store/globaldb/global_db_bundles.go b/internal/store/globaldb/global_db_bundles.go
index d7cc09d57..f02ff4e0a 100644
--- a/internal/store/globaldb/global_db_bundles.go
+++ b/internal/store/globaldb/global_db_bundles.go
@@ -3,363 +3,71 @@ package globaldb
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"strings"
-
- modelpkg "github.com/pedronauck/agh/internal/bundles/model"
- "github.com/pedronauck/agh/internal/store"
)
-func (g *GlobalDB) CreateBundleActivation(ctx context.Context, activation modelpkg.Activation) error {
- if err := g.checkReady(ctx, "create bundle activation"); err != nil {
- return err
- }
- if err := activation.Validate(); err != nil {
- return err
- }
- if activation.CreatedAt.IsZero() {
- activation.CreatedAt = g.now()
- }
- if activation.UpdatedAt.IsZero() {
- activation.UpdatedAt = activation.CreatedAt
- }
-
- _, err := g.db.ExecContext(
- ctx,
- `INSERT INTO bundle_activations (
- id, extension_name, bundle_name, profile_name, scope, workspace_id,
- spec_content_hash, bind_primary_channel_default, created_at,
- updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- activation.ID,
- activation.ExtensionName,
- activation.BundleName,
- activation.ProfileName,
- string(activation.Scope),
- store.NullableString(activation.WorkspaceID),
- store.NullableString(activation.SpecContentHash),
- activation.BindPrimaryChannelAsDefault,
- store.FormatTimestamp(activation.CreatedAt),
- store.FormatTimestamp(activation.UpdatedAt),
- )
- if err != nil {
- return fmt.Errorf("store: create bundle activation %q: %w", activation.ID, err)
- }
- return nil
-}
-
-func (g *GlobalDB) UpdateBundleActivation(ctx context.Context, activation modelpkg.Activation) error {
- if err := g.checkReady(ctx, "update bundle activation"); err != nil {
- return err
- }
- if err := activation.Validate(); err != nil {
- return err
- }
- if activation.UpdatedAt.IsZero() {
- activation.UpdatedAt = g.now()
- }
+const bundleActivationResourceKind = "bundle.activation"
- result, err := g.db.ExecContext(
- ctx,
- `UPDATE bundle_activations
- SET extension_name = ?, bundle_name = ?, profile_name = ?, scope = ?,
- workspace_id = ?, spec_content_hash = ?,
- bind_primary_channel_default = ?, updated_at = ?
- WHERE id = ?`,
- activation.ExtensionName,
- activation.BundleName,
- activation.ProfileName,
- string(activation.Scope),
- store.NullableString(activation.WorkspaceID),
- store.NullableString(activation.SpecContentHash),
- activation.BindPrimaryChannelAsDefault,
- store.FormatTimestamp(activation.UpdatedAt),
- activation.ID,
- )
- if err != nil {
- return fmt.Errorf("store: update bundle activation %q: %w", activation.ID, err)
- }
- affected, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("store: rows affected for bundle activation %q: %w", activation.ID, err)
- }
- if affected == 0 {
- return fmt.Errorf("store: bundle activation %q: %w", activation.ID, modelpkg.ErrActivationNotFound)
- }
- return nil
+type bundleActivationResourceSpec struct {
+ ExtensionName string `json:"extension_name"`
}
-func (g *GlobalDB) DeleteBundleActivation(ctx context.Context, id string) error {
- if err := g.checkReady(ctx, "delete bundle activation"); err != nil {
- return err
- }
-
- trimmed := strings.TrimSpace(id)
- if trimmed == "" {
- return errors.New("store: bundle activation id is required")
- }
- result, err := g.db.ExecContext(ctx, `DELETE FROM bundle_activations WHERE id = ?`, trimmed)
- if err != nil {
- return fmt.Errorf("store: delete bundle activation %q: %w", trimmed, err)
- }
- affected, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("store: rows affected for bundle activation %q: %w", trimmed, err)
- }
- if affected == 0 {
- return fmt.Errorf("store: bundle activation %q: %w", trimmed, modelpkg.ErrActivationNotFound)
- }
- return nil
-}
-
-func (g *GlobalDB) GetBundleActivation(ctx context.Context, id string) (modelpkg.Activation, error) {
- if err := g.checkReady(ctx, "get bundle activation"); err != nil {
- return modelpkg.Activation{}, err
+func (g *GlobalDB) CountBundleActivationsForExtension(ctx context.Context, extensionName string) (int, error) {
+ if err := g.checkReady(ctx, "count bundle activations for extension"); err != nil {
+ return 0, err
}
- trimmed := strings.TrimSpace(id)
+ trimmed := strings.TrimSpace(extensionName)
if trimmed == "" {
- return modelpkg.Activation{}, errors.New("store: bundle activation id is required")
- }
-
- row := g.db.QueryRowContext(
- ctx,
- `SELECT
- id, extension_name, bundle_name, profile_name, scope, workspace_id,
- spec_content_hash, bind_primary_channel_default, created_at,
- updated_at
- FROM bundle_activations WHERE id = ?`,
- trimmed,
- )
- activation, err := scanBundleActivation(row)
- if errors.Is(err, sql.ErrNoRows) {
- return modelpkg.Activation{}, modelpkg.ErrActivationNotFound
+ return 0, errors.New("store: extension name is required")
}
+ count, err := countBundleActivationResourcesForExtension(ctx, g.db, trimmed)
if err != nil {
- return modelpkg.Activation{}, err
+ return 0, fmt.Errorf("store: count bundle activations for extension %q: %w", trimmed, err)
}
- return activation, nil
+ return count, nil
}
-func (g *GlobalDB) ListBundleActivations(ctx context.Context) ([]modelpkg.Activation, error) {
- if err := g.checkReady(ctx, "list bundle activations"); err != nil {
- return nil, err
- }
-
- rows, err := g.db.QueryContext(
+func countBundleActivationResourcesForExtension(
+ ctx context.Context,
+ db *sql.DB,
+ extensionName string,
+) (int, error) {
+ rows, err := db.QueryContext(
ctx,
- `SELECT
- id, extension_name, bundle_name, profile_name, scope, workspace_id,
- spec_content_hash, bind_primary_channel_default, created_at,
- updated_at
- FROM bundle_activations
- ORDER BY extension_name ASC, bundle_name ASC, profile_name ASC, created_at ASC, id ASC`,
+ `SELECT spec_json FROM resource_records WHERE kind = ?`,
+ bundleActivationResourceKind,
)
if err != nil {
- return nil, fmt.Errorf("store: query bundle activations: %w", err)
+ if strings.Contains(strings.ToLower(err.Error()), "no such table") {
+ return 0, nil
+ }
+ return 0, err
}
defer func() {
_ = rows.Close()
}()
- activations := make([]modelpkg.Activation, 0)
+ count := 0
+ trimmed := strings.TrimSpace(extensionName)
for rows.Next() {
- activation, scanErr := scanBundleActivation(rows)
- if scanErr != nil {
- return nil, scanErr
- }
- activations = append(activations, activation)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("store: iterate bundle activations: %w", err)
- }
- return activations, nil
-}
-
-func (g *GlobalDB) ReplaceBundleActivationInventory(
- ctx context.Context,
- activationID string,
- items []modelpkg.InventoryItem,
-) (err error) {
- if err := g.checkReady(ctx, "replace bundle activation inventory"); err != nil {
- return err
- }
-
- trimmedID := strings.TrimSpace(activationID)
- if trimmedID == "" {
- return errors.New("store: bundle activation id is required")
- }
-
- tx, err := g.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("store: begin bundle activation inventory transaction: %w", err)
- }
- defer func() {
- joinCleanupError(&err, rollbackTx(tx, "bundle activation inventory"))
- }()
-
- if _, err := tx.ExecContext(
- ctx,
- `DELETE FROM bundle_activation_inventory WHERE activation_id = ?`,
- trimmedID,
- ); err != nil {
- return fmt.Errorf("store: clear bundle activation inventory %q: %w", trimmedID, err)
- }
-
- for _, item := range items {
- next := item
- next.ActivationID = trimmedID
- if err := next.Validate(); err != nil {
- return err
+ var raw string
+ if err := rows.Scan(&raw); err != nil {
+ return 0, fmt.Errorf("scan bundle activation resource: %w", err)
}
- recordedAt := next.RecordedAtUTC
- if recordedAt.IsZero() {
- recordedAt = g.now()
+ var spec bundleActivationResourceSpec
+ if err := json.Unmarshal([]byte(raw), &spec); err != nil {
+ return 0, fmt.Errorf("decode bundle activation resource: %w", err)
}
- if _, err := tx.ExecContext(
- ctx,
- `INSERT INTO bundle_activation_inventory (
- activation_id, resource_kind, resource_id, resource_name, recorded_at
- ) VALUES (?, ?, ?, ?, ?)`,
- next.ActivationID,
- next.ResourceKind,
- next.ResourceID,
- next.ResourceName,
- store.FormatTimestamp(recordedAt),
- ); err != nil {
- return fmt.Errorf("store: insert bundle activation inventory %q/%q: %w", trimmedID, next.ResourceID, err)
- }
- }
-
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("store: commit bundle activation inventory %q: %w", trimmedID, err)
- }
- return nil
-}
-
-func (g *GlobalDB) ListBundleActivationInventory(
- ctx context.Context,
- activationID string,
-) ([]modelpkg.InventoryItem, error) {
- if err := g.checkReady(ctx, "list bundle activation inventory"); err != nil {
- return nil, err
- }
-
- trimmedID := strings.TrimSpace(activationID)
- if trimmedID == "" {
- return nil, errors.New("store: bundle activation id is required")
- }
-
- rows, err := g.db.QueryContext(
- ctx,
- `SELECT activation_id, resource_kind, resource_id, resource_name, recorded_at
- FROM bundle_activation_inventory
- WHERE activation_id = ?
- ORDER BY resource_kind ASC, resource_name ASC, resource_id ASC`,
- trimmedID,
- )
- if err != nil {
- return nil, fmt.Errorf("store: query bundle activation inventory %q: %w", trimmedID, err)
- }
- defer func() {
- _ = rows.Close()
- }()
-
- items := make([]modelpkg.InventoryItem, 0)
- for rows.Next() {
- item, scanErr := scanBundleInventoryItem(rows)
- if scanErr != nil {
- return nil, scanErr
+ if strings.TrimSpace(spec.ExtensionName) == trimmed {
+ count++
}
- items = append(items, item)
}
if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("store: iterate bundle activation inventory %q: %w", trimmedID, err)
- }
- return items, nil
-}
-
-func (g *GlobalDB) CountBundleActivationsForExtension(ctx context.Context, extensionName string) (int, error) {
- if err := g.checkReady(ctx, "count bundle activations for extension"); err != nil {
- return 0, err
- }
-
- trimmed := strings.TrimSpace(extensionName)
- if trimmed == "" {
- return 0, errors.New("store: extension name is required")
- }
- row := g.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM bundle_activations WHERE extension_name = ?`, trimmed)
-
- var count int
- if err := row.Scan(&count); err != nil {
- return 0, fmt.Errorf("store: count bundle activations for extension %q: %w", trimmed, err)
+ return 0, fmt.Errorf("iterate bundle activation resources: %w", err)
}
return count, nil
}
-
-func scanBundleActivation(scanner interface{ Scan(...any) error }) (modelpkg.Activation, error) {
- var (
- activation modelpkg.Activation
- scopeRaw string
- workspaceID sql.NullString
- specHash sql.NullString
- createdRaw string
- updatedRaw string
- )
- if err := scanner.Scan(
- &activation.ID,
- &activation.ExtensionName,
- &activation.BundleName,
- &activation.ProfileName,
- &scopeRaw,
- &workspaceID,
- &specHash,
- &activation.BindPrimaryChannelAsDefault,
- &createdRaw,
- &updatedRaw,
- ); err != nil {
- return modelpkg.Activation{}, fmt.Errorf("store: scan bundle activation: %w", err)
- }
- activation.Scope = modelpkg.Scope(scopeRaw).Normalize()
- activation.WorkspaceID = strings.TrimSpace(workspaceID.String)
- activation.SpecContentHash = strings.TrimSpace(specHash.String)
-
- createdAt, err := store.ParseTimestamp(createdRaw)
- if err != nil {
- return modelpkg.Activation{}, fmt.Errorf("store: parse bundle activation created_at %q: %w", createdRaw, err)
- }
- updatedAt, err := store.ParseTimestamp(updatedRaw)
- if err != nil {
- return modelpkg.Activation{}, fmt.Errorf("store: parse bundle activation updated_at %q: %w", updatedRaw, err)
- }
- activation.CreatedAt = createdAt
- activation.UpdatedAt = updatedAt
- return activation, nil
-}
-
-func scanBundleInventoryItem(scanner interface{ Scan(...any) error }) (modelpkg.InventoryItem, error) {
- var (
- item modelpkg.InventoryItem
- recordedRaw string
- )
- if err := scanner.Scan(
- &item.ActivationID,
- &item.ResourceKind,
- &item.ResourceID,
- &item.ResourceName,
- &recordedRaw,
- ); err != nil {
- return modelpkg.InventoryItem{}, fmt.Errorf("store: scan bundle activation inventory: %w", err)
- }
- recordedAt, err := store.ParseTimestamp(recordedRaw)
- if err != nil {
- return modelpkg.InventoryItem{}, fmt.Errorf(
- "store: parse bundle activation inventory recorded_at %q: %w",
- recordedRaw,
- err,
- )
- }
- item.RecordedAtUTC = recordedAt
- return item, nil
-}
diff --git a/internal/store/globaldb/global_db_bundles_test.go b/internal/store/globaldb/global_db_bundles_test.go
index 7f9db704c..e2077ab07 100644
--- a/internal/store/globaldb/global_db_bundles_test.go
+++ b/internal/store/globaldb/global_db_bundles_test.go
@@ -1,274 +1,90 @@
package globaldb
import (
- "database/sql"
- "path/filepath"
"testing"
"time"
- bundlemodel "github.com/pedronauck/agh/internal/bundles/model"
"github.com/pedronauck/agh/internal/store"
"github.com/pedronauck/agh/internal/testutil"
)
-func TestOpenGlobalDBCreatesBundleActivationTableWithExpectedColumns(t *testing.T) {
+func TestOpenGlobalDBDoesNotCreateLegacyBundleActivationTables(t *testing.T) {
t.Parallel()
globalDB := openTestGlobalDB(t)
-
- assertTableColumns(t, globalDB.db, "bundle_activations", []string{
- "id",
- "extension_name",
- "bundle_name",
- "profile_name",
- "scope",
- "workspace_id",
- "spec_content_hash",
- "bind_primary_channel_default",
- "created_at",
- "updated_at",
- })
-}
-
-func TestOpenGlobalDBMigratesLegacyBundleActivationSpecHashColumn(t *testing.T) {
- t.Parallel()
-
- path := filepath.Join(t.TempDir(), GlobalDatabaseName)
- db, err := sql.Open(sqliteDriverName, sqliteDSN(path))
- if err != nil {
- t.Fatalf("sql.Open() error = %v", err)
- }
- if _, err := db.ExecContext(testutil.Context(t), `CREATE TABLE bundle_activations (
- id TEXT PRIMARY KEY,
- extension_name TEXT NOT NULL,
- bundle_name TEXT NOT NULL,
- profile_name TEXT NOT NULL,
- scope TEXT NOT NULL,
- workspace_id TEXT,
- bind_primary_channel_default BOOLEAN NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- )`); err != nil {
- t.Fatalf("ExecContext(create legacy bundle_activations) error = %v", err)
- }
- legacyCreatedAt := time.Date(2026, 4, 14, 20, 0, 0, 0, time.UTC)
- if _, err := db.ExecContext(
- testutil.Context(t),
- `INSERT INTO bundle_activations (
- id, extension_name, bundle_name, profile_name, scope, workspace_id, bind_primary_channel_default, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- "act-legacy",
- "marketing-team",
- "marketing",
- "default",
- string(bundlemodel.ScopeGlobal),
- nil,
- true,
- store.FormatTimestamp(legacyCreatedAt),
- store.FormatTimestamp(legacyCreatedAt),
- ); err != nil {
- t.Fatalf("ExecContext(insert legacy bundle activation) error = %v", err)
- }
- if err := db.Close(); err != nil {
- t.Fatalf("db.Close() error = %v", err)
- }
-
- globalDB, err := OpenGlobalDB(testutil.Context(t), path)
- if err != nil {
- t.Fatalf("OpenGlobalDB() error = %v", err)
- }
- t.Cleanup(func() {
- if err := globalDB.Close(testutil.Context(t)); err != nil {
- t.Fatalf("Close() error = %v", err)
+ for _, table := range []string{"bundle_activations", "bundle_activation_inventory"} {
+ exists, err := tableExists(testutil.Context(t), globalDB.db, table)
+ if err != nil {
+ t.Fatalf("tableExists(%s) error = %v", table, err)
+ }
+ if exists {
+ t.Fatalf("tableExists(%s) = true, want false after resource cutover", table)
}
- })
-
- assertTableColumns(t, globalDB.db, "bundle_activations", []string{
- "id",
- "extension_name",
- "bundle_name",
- "profile_name",
- "scope",
- "workspace_id",
- "bind_primary_channel_default",
- "created_at",
- "updated_at",
- "spec_content_hash",
- })
-
- loaded, err := globalDB.GetBundleActivation(testutil.Context(t), "act-legacy")
- if err != nil {
- t.Fatalf("GetBundleActivation() error = %v", err)
- }
- if got, want := loaded.ExtensionName, "marketing-team"; got != want {
- t.Fatalf("loaded.ExtensionName = %q, want %q", got, want)
- }
- if got, want := loaded.BundleName, "marketing"; got != want {
- t.Fatalf("loaded.BundleName = %q, want %q", got, want)
- }
- if got, want := loaded.ProfileName, "default"; got != want {
- t.Fatalf("loaded.ProfileName = %q, want %q", got, want)
- }
- if got, want := loaded.Scope, bundlemodel.ScopeGlobal; got != want {
- t.Fatalf("loaded.Scope = %q, want %q", got, want)
- }
- if got := loaded.SpecContentHash; got != "" {
- t.Fatalf("loaded.SpecContentHash = %q, want empty for migrated legacy row", got)
- }
-}
-
-func TestGlobalDBBundleActivationRoundTripWithSpecHashAndInventory(t *testing.T) {
- t.Parallel()
-
- globalDB := openTestGlobalDB(t)
- now := time.Date(2026, 4, 14, 23, 0, 0, 0, time.UTC)
- if _, err := globalDB.db.ExecContext(
- testutil.Context(t),
- `INSERT INTO extensions (name, version, source, enabled, manifest_path, installed_at, capabilities, actions, checksum)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- "marketing-team",
- "1.0.0",
- "managed",
- true,
- "/tmp/marketing-team/extension.toml",
- store.FormatTimestamp(now),
- "{}",
- "{}",
- "checksum",
- ); err != nil {
- t.Fatalf("insert extension row error = %v", err)
- }
-
- workspaceID := registerWorkspaceForGlobalTests(
- t,
- globalDB,
- "bundle-workspace",
- filepath.Join(t.TempDir(), "bundle-workspace"),
- )
- activation := bundlemodel.Activation{
- ID: "act_marketing",
- ExtensionName: "marketing-team",
- BundleName: "marketing",
- ProfileName: "default",
- Scope: bundlemodel.ScopeWorkspace,
- WorkspaceID: workspaceID,
- SpecContentHash: "abc123",
- BindPrimaryChannelAsDefault: true,
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := globalDB.CreateBundleActivation(testutil.Context(t), activation); err != nil {
- t.Fatalf("CreateBundleActivation() error = %v", err)
- }
-
- loaded, err := globalDB.GetBundleActivation(testutil.Context(t), activation.ID)
- if err != nil {
- t.Fatalf("GetBundleActivation() error = %v", err)
- }
- if loaded.SpecContentHash != activation.SpecContentHash {
- t.Fatalf("SpecContentHash = %q, want %q", loaded.SpecContentHash, activation.SpecContentHash)
- }
- if loaded.WorkspaceID != workspaceID {
- t.Fatalf("WorkspaceID = %q, want %q", loaded.WorkspaceID, workspaceID)
- }
-
- activation.BindPrimaryChannelAsDefault = false
- activation.SpecContentHash = "def456"
- activation.UpdatedAt = now.Add(time.Minute)
- if err := globalDB.UpdateBundleActivation(testutil.Context(t), activation); err != nil {
- t.Fatalf("UpdateBundleActivation() error = %v", err)
- }
-
- listed, err := globalDB.ListBundleActivations(testutil.Context(t))
- if err != nil {
- t.Fatalf("ListBundleActivations() error = %v", err)
- }
- if got, want := len(listed), 1; got != want {
- t.Fatalf("len(ListBundleActivations()) = %d, want %d", got, want)
- }
- if listed[0].SpecContentHash != "def456" {
- t.Fatalf("listed[0].SpecContentHash = %q, want def456", listed[0].SpecContentHash)
- }
-
- inventory := []bundlemodel.InventoryItem{{
- ActivationID: activation.ID,
- ResourceKind: "bridge_instance",
- ResourceID: "bri_123",
- ResourceName: "Marketing Telegram",
- RecordedAtUTC: now,
- }}
- if err := globalDB.ReplaceBundleActivationInventory(testutil.Context(t), activation.ID, inventory); err != nil {
- t.Fatalf("ReplaceBundleActivationInventory() error = %v", err)
- }
-
- loadedInventory, err := globalDB.ListBundleActivationInventory(testutil.Context(t), activation.ID)
- if err != nil {
- t.Fatalf("ListBundleActivationInventory() error = %v", err)
- }
- if got, want := len(loadedInventory), 1; got != want {
- t.Fatalf("len(ListBundleActivationInventory()) = %d, want %d", got, want)
- }
- if loadedInventory[0].ResourceID != inventory[0].ResourceID {
- t.Fatalf("inventory resource id = %q, want %q", loadedInventory[0].ResourceID, inventory[0].ResourceID)
- }
-
- if err := globalDB.DeleteBundleActivation(testutil.Context(t), activation.ID); err != nil {
- t.Fatalf("DeleteBundleActivation() error = %v", err)
- }
- if _, err := globalDB.GetBundleActivation(testutil.Context(t), activation.ID); err == nil {
- t.Fatal("GetBundleActivation() after delete error = nil, want not found")
- }
-
- remainingInventory, err := globalDB.ListBundleActivationInventory(testutil.Context(t), activation.ID)
- if err != nil {
- t.Fatalf("ListBundleActivationInventory(after delete) error = %v", err)
- }
- if got := len(remainingInventory); got != 0 {
- t.Fatalf("len(ListBundleActivationInventory(after delete)) = %d, want 0", got)
}
}
-func TestGlobalDBBundleActivationCountByExtension(t *testing.T) {
+func TestGlobalDBBundleActivationCountByExtensionUsesResourceRecords(t *testing.T) {
t.Parallel()
globalDB := openTestGlobalDB(t)
- now := time.Date(2026, 4, 14, 23, 30, 0, 0, time.UTC)
- if _, err := globalDB.db.ExecContext(
- testutil.Context(t),
- `INSERT INTO extensions (name, version, source, enabled, manifest_path, installed_at, capabilities, actions, checksum)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- "marketing-team",
- "1.0.0",
- "managed",
- true,
- "/tmp/marketing-team/extension.toml",
- store.FormatTimestamp(now),
- "{}",
- "{}",
- "checksum",
- ); err != nil {
- t.Fatalf("insert extension row error = %v", err)
- }
-
- activation := bundlemodel.Activation{
- ID: "act_count",
- ExtensionName: "marketing-team",
- BundleName: "marketing",
- ProfileName: "default",
- Scope: bundlemodel.ScopeGlobal,
- SpecContentHash: "hash",
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := globalDB.CreateBundleActivation(testutil.Context(t), activation); err != nil {
- t.Fatalf("CreateBundleActivation() error = %v", err)
+ now := store.FormatTimestamp(time.Date(2026, 4, 14, 23, 30, 0, 0, time.UTC))
+ for _, row := range []struct {
+ id string
+ spec string
+ }{
+ {
+ id: "act_marketing_alpha",
+ spec: `{
+ "extension_name":"marketing-team",
+ "bundle_name":"marketing",
+ "profile_name":"default"
+ }`,
+ },
+ {
+ id: "act_marketing_beta",
+ spec: `{
+ "extension_name":"marketing-team",
+ "bundle_name":"marketing",
+ "profile_name":"beta"
+ }`,
+ },
+ {
+ id: "act_ops",
+ spec: `{
+ "extension_name":"ops-team",
+ "bundle_name":"ops",
+ "profile_name":"default"
+ }`,
+ },
+ } {
+ if _, err := globalDB.db.ExecContext(
+ testutil.Context(t),
+ `INSERT INTO resource_records (
+ kind, id, version, scope_kind, scope_id, owner_kind, owner_id,
+ source_kind, source_id, spec_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ bundleActivationResourceKind,
+ row.id,
+ 1,
+ "global",
+ nil,
+ "daemon",
+ "bundle-test",
+ "daemon",
+ "bundle-test",
+ row.spec,
+ now,
+ now,
+ ); err != nil {
+ t.Fatalf("insert resource %s error = %v", row.id, err)
+ }
}
count, err := globalDB.CountBundleActivationsForExtension(testutil.Context(t), "marketing-team")
if err != nil {
t.Fatalf("CountBundleActivationsForExtension() error = %v", err)
}
- if got, want := count, 1; got != want {
+ if got, want := count, 2; got != want {
t.Fatalf("CountBundleActivationsForExtension() = %d, want %d", got, want)
}
}
diff --git a/internal/store/globaldb/global_db_extra_test.go b/internal/store/globaldb/global_db_extra_test.go
index b246f70fa..b7babf86e 100644
--- a/internal/store/globaldb/global_db_extra_test.go
+++ b/internal/store/globaldb/global_db_extra_test.go
@@ -39,6 +39,70 @@ func TestGlobalDBPathAndCloseVariants(t *testing.T) {
}
}
+func TestGlobalDBTransactionCleanupHelpers(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t)
+ db, err := store.OpenSQLiteDatabase(ctx, filepath.Join(t.TempDir(), "cleanup.db"), nil)
+ if err != nil {
+ t.Fatalf("OpenSQLiteDatabase() error = %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ if err := rollbackTx(nil, "nil"); err != nil {
+ t.Fatalf("rollbackTx(nil) error = %v", err)
+ }
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ t.Fatalf("BeginTx() error = %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatalf("Commit() error = %v", err)
+ }
+ if err := rollbackTx(tx, "committed"); err != nil {
+ t.Fatalf("rollbackTx(committed) error = %v", err)
+ }
+
+ conn, err := db.Conn(ctx)
+ if err != nil {
+ t.Fatalf("Conn() error = %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+ if err := rollbackImmediate(ctx, nil, "nil"); err != nil {
+ t.Fatalf("rollbackImmediate(nil) error = %v", err)
+ }
+ if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
+ t.Fatalf("BEGIN IMMEDIATE error = %v", err)
+ }
+ if err := rollbackImmediate(ctx, conn, "cleanup"); err != nil {
+ t.Fatalf("rollbackImmediate(active) error = %v", err)
+ }
+ if err := restoreForeignKeys(ctx, nil); err != nil {
+ t.Fatalf("restoreForeignKeys(nil) error = %v", err)
+ }
+ if err := restoreForeignKeys(ctx, conn); err != nil {
+ t.Fatalf("restoreForeignKeys(conn) error = %v", err)
+ }
+
+ primaryErr := errors.New("primary")
+ cleanupErr := errors.New("cleanup")
+ joinCleanupError(nil, cleanupErr)
+ var target error
+ joinCleanupError(&target, nil)
+ if target != nil {
+ t.Fatalf("joinCleanupError(nil cleanup) = %v, want nil", target)
+ }
+ joinCleanupError(&target, cleanupErr)
+ if !errors.Is(target, cleanupErr) {
+ t.Fatalf("joinCleanupError(cleanup only) = %v, want cleanup", target)
+ }
+ target = primaryErr
+ joinCleanupError(&target, cleanupErr)
+ if !errors.Is(target, primaryErr) || !errors.Is(target, cleanupErr) {
+ t.Fatalf("joinCleanupError(joined) = %v, want primary and cleanup", target)
+ }
+}
+
func TestGlobalDBGuardClauses(t *testing.T) {
t.Parallel()
@@ -95,6 +159,98 @@ func TestGlobalDBGuardClauses(t *testing.T) {
}
}
+func TestGlobalDBWorkspaceAndAutomationGuardClauses(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t)
+ assertErr := func(name string, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("%s error = nil, want non-nil", name)
+ }
+ }
+
+ var nilDB *GlobalDB
+ assertErr("InsertWorkspace(nil receiver)", nilDB.InsertWorkspace(ctx, aghworkspace.Workspace{}))
+ assertErr("UpdateWorkspace(nil receiver)", nilDB.UpdateWorkspace(ctx, aghworkspace.Workspace{}))
+ assertErr("DeleteWorkspace(nil receiver)", nilDB.DeleteWorkspace(ctx, "ws-1"))
+ _, err := nilDB.GetWorkspace(ctx, "ws-1")
+ assertErr("GetWorkspace(nil receiver)", err)
+ _, err = nilDB.GetWorkspaceByPath(ctx, "/tmp/ws-1")
+ assertErr("GetWorkspaceByPath(nil receiver)", err)
+ _, err = nilDB.GetWorkspaceByName(ctx, "ws-1")
+ assertErr("GetWorkspaceByName(nil receiver)", err)
+ _, err = nilDB.ListWorkspaces(ctx)
+ assertErr("ListWorkspaces(nil receiver)", err)
+
+ _, err = nilDB.CreateJob(ctx, Job{})
+ assertErr("CreateJob(nil receiver)", err)
+ _, err = nilDB.UpdateJob(ctx, Job{})
+ assertErr("UpdateJob(nil receiver)", err)
+ assertErr("DeleteJob(nil receiver)", nilDB.DeleteJob(ctx, "job-1"))
+ _, err = nilDB.GetJob(ctx, "job-1")
+ assertErr("GetJob(nil receiver)", err)
+ _, err = nilDB.ListJobs(ctx, JobListQuery{})
+ assertErr("ListJobs(nil receiver)", err)
+ _, err = nilDB.CreateTrigger(ctx, Trigger{})
+ assertErr("CreateTrigger(nil receiver)", err)
+ _, err = nilDB.UpdateTrigger(ctx, Trigger{})
+ assertErr("UpdateTrigger(nil receiver)", err)
+ assertErr("DeleteTrigger(nil receiver)", nilDB.DeleteTrigger(ctx, "trigger-1"))
+ _, err = nilDB.GetTrigger(ctx, "trigger-1")
+ assertErr("GetTrigger(nil receiver)", err)
+ _, err = nilDB.GetTriggerByWebhookID(ctx, "webhook-1")
+ assertErr("GetTriggerByWebhookID(nil receiver)", err)
+ _, err = nilDB.ListTriggers(ctx, TriggerListQuery{})
+ assertErr("ListTriggers(nil receiver)", err)
+ _, err = nilDB.CreateRun(ctx, Run{})
+ assertErr("CreateRun(nil receiver)", err)
+ _, err = nilDB.UpdateRun(ctx, Run{})
+ assertErr("UpdateRun(nil receiver)", err)
+ assertErr("DeleteRun(nil receiver)", nilDB.DeleteRun(ctx, "run-1"))
+ _, err = nilDB.GetRun(ctx, "run-1")
+ assertErr("GetRun(nil receiver)", err)
+ _, err = nilDB.ListRuns(ctx, RunQuery{})
+ assertErr("ListRuns(nil receiver)", err)
+ _, err = nilDB.CountRuns(ctx, RunQuery{})
+ assertErr("CountRuns(nil receiver)", err)
+ _, err = nilDB.SetJobEnabledOverlay(ctx, JobEnabledOverlay{})
+ assertErr("SetJobEnabledOverlay(nil receiver)", err)
+ _, err = nilDB.GetJobEnabledOverlay(ctx, "job-1")
+ assertErr("GetJobEnabledOverlay(nil receiver)", err)
+ _, err = nilDB.ListJobEnabledOverlays(ctx)
+ assertErr("ListJobEnabledOverlays(nil receiver)", err)
+ assertErr("DeleteJobEnabledOverlay(nil receiver)", nilDB.DeleteJobEnabledOverlay(ctx, "job-1"))
+ _, err = nilDB.SetTriggerEnabledOverlay(ctx, TriggerEnabledOverlay{})
+ assertErr("SetTriggerEnabledOverlay(nil receiver)", err)
+ _, err = nilDB.GetTriggerEnabledOverlay(ctx, "trigger-1")
+ assertErr("GetTriggerEnabledOverlay(nil receiver)", err)
+ _, err = nilDB.ListTriggerEnabledOverlays(ctx)
+ assertErr("ListTriggerEnabledOverlays(nil receiver)", err)
+ assertErr("DeleteTriggerEnabledOverlay(nil receiver)", nilDB.DeleteTriggerEnabledOverlay(ctx, "trigger-1"))
+ assertErr("SetTriggerWebhookSecret(nil receiver)", nilDB.SetTriggerWebhookSecret(ctx, "trigger-1", "secret"))
+ _, err = nilDB.GetTriggerWebhookSecret(ctx, "trigger-1")
+ assertErr("GetTriggerWebhookSecret(nil receiver)", err)
+ assertErr("DeleteTriggerWebhookSecret(nil receiver)", nilDB.DeleteTriggerWebhookSecret(ctx, "trigger-1"))
+
+ globalDB := openTestGlobalDB(t)
+ assertErr("InsertWorkspace(nil ctx)", globalDB.InsertWorkspace(nilGlobalContext(), aghworkspace.Workspace{}))
+ assertErr("UpdateWorkspace(nil ctx)", globalDB.UpdateWorkspace(nilGlobalContext(), aghworkspace.Workspace{}))
+ assertErr("DeleteWorkspace(nil ctx)", globalDB.DeleteWorkspace(nilGlobalContext(), "ws-1"))
+ _, err = globalDB.GetWorkspace(nilGlobalContext(), "ws-1")
+ assertErr("GetWorkspace(nil ctx)", err)
+ _, err = globalDB.ListWorkspaces(nilGlobalContext())
+ assertErr("ListWorkspaces(nil ctx)", err)
+ _, err = globalDB.CreateJob(nilGlobalContext(), Job{})
+ assertErr("CreateJob(nil ctx)", err)
+ _, err = globalDB.CreateTrigger(nilGlobalContext(), Trigger{})
+ assertErr("CreateTrigger(nil ctx)", err)
+ _, err = globalDB.CreateRun(nilGlobalContext(), Run{})
+ assertErr("CreateRun(nil ctx)", err)
+ _, err = globalDB.CountRuns(nilGlobalContext(), RunQuery{})
+ assertErr("CountRuns(nil ctx)", err)
+}
+
func TestGlobalDBDefaultsAndFilteredListings(t *testing.T) {
t.Parallel()
@@ -443,6 +599,47 @@ func TestMigrateBridgeInstanceColumnsNoopAndIdempotent(t *testing.T) {
}
}
+func TestMigrateWorkspaceColumnsAddsEnvironmentRef(t *testing.T) {
+ t.Parallel()
+
+ db, err := store.OpenSQLiteDatabase(testutil.Context(t), filepath.Join(t.TempDir(), "workspace-columns.db"), nil)
+ if err != nil {
+ t.Fatalf("OpenSQLiteDatabase() error = %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ if err := migrateWorkspaceColumns(testutil.Context(t), db); err != nil {
+ t.Fatalf("migrateWorkspaceColumns(no table) error = %v", err)
+ }
+
+ if _, err := db.ExecContext(testutil.Context(t), `CREATE TABLE workspaces (
+ id TEXT PRIMARY KEY,
+ root_dir TEXT NOT NULL UNIQUE,
+ add_dirs TEXT NOT NULL DEFAULT '[]',
+ name TEXT NOT NULL UNIQUE,
+ default_agent TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )`); err != nil {
+ t.Fatalf("create workspaces error = %v", err)
+ }
+
+ if err := migrateWorkspaceColumns(testutil.Context(t), db); err != nil {
+ t.Fatalf("migrateWorkspaceColumns(add column) error = %v", err)
+ }
+ if err := migrateWorkspaceColumns(testutil.Context(t), db); err != nil {
+ t.Fatalf("migrateWorkspaceColumns(idempotent) error = %v", err)
+ }
+
+ columns, err := tableColumns(testutil.Context(t), db, "workspaces")
+ if err != nil {
+ t.Fatalf("tableColumns(workspaces) error = %v", err)
+ }
+ if _, ok := columns["environment_ref"]; !ok {
+ t.Fatalf("tableColumns(workspaces) missing environment_ref in %#v", columns)
+ }
+}
+
func TestMigrateGlobalSchemaUpgradesLegacyBridgeAndExtensionTables(t *testing.T) {
t.Parallel()
@@ -464,6 +661,15 @@ func TestMigrateGlobalSchemaUpgradesLegacyBridgeAndExtensionTables(t *testing.T)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
+ `CREATE TABLE workspaces (
+ id TEXT PRIMARY KEY,
+ root_dir TEXT NOT NULL UNIQUE,
+ add_dirs TEXT NOT NULL DEFAULT '[]',
+ name TEXT NOT NULL UNIQUE,
+ default_agent TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )`,
`CREATE TABLE extensions (
name TEXT PRIMARY KEY,
version TEXT NOT NULL,
@@ -489,15 +695,6 @@ func TestMigrateGlobalSchemaUpgradesLegacyBridgeAndExtensionTables(t *testing.T)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
- `CREATE TABLE bundle_activations (
- scope TEXT NOT NULL,
- workspace_id TEXT,
- bundle_name TEXT NOT NULL,
- profile_name TEXT NOT NULL,
- manifest_path TEXT NOT NULL,
- installed_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- )`,
`CREATE TABLE network_audit_log (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
@@ -522,10 +719,10 @@ func TestMigrateGlobalSchemaUpgradesLegacyBridgeAndExtensionTables(t *testing.T)
}
for table, expected := range map[string][]string{
- "sessions": {"stop_reason", "stop_detail", "channel"},
- "extensions": {"registry_slug", "registry_name", "remote_version"},
- "bridge_instances": {"source", "dm_policy", "provider_config", "degradation_reason", "degradation_message"},
- "bundle_activations": {"spec_content_hash"},
+ "sessions": {"stop_reason", "stop_detail", "channel"},
+ "workspaces": {"environment_ref"},
+ "extensions": {"registry_slug", "registry_name", "remote_version"},
+ "bridge_instances": {"source", "dm_policy", "provider_config", "degradation_reason", "degradation_message"},
} {
columns, err := tableColumns(testutil.Context(t), db, table)
if err != nil {
diff --git a/internal/store/globaldb/global_db_resources_integration_test.go b/internal/store/globaldb/global_db_resources_integration_test.go
new file mode 100644
index 000000000..a2f293bd5
--- /dev/null
+++ b/internal/store/globaldb/global_db_resources_integration_test.go
@@ -0,0 +1,45 @@
+//go:build integration
+
+package globaldb
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestOpenGlobalDBBootstrapsResourceSchemaIntegration(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), GlobalDatabaseName)
+
+ first, err := OpenGlobalDB(testutil.Context(t), path)
+ if err != nil {
+ t.Fatalf("OpenGlobalDB(first) error = %v", err)
+ }
+ if err := first.Close(testutil.Context(t)); err != nil {
+ t.Fatalf("Close(first) error = %v", err)
+ }
+
+ second, err := OpenGlobalDB(testutil.Context(t), path)
+ if err != nil {
+ t.Fatalf("OpenGlobalDB(second) error = %v", err)
+ }
+ t.Cleanup(func() {
+ if closeErr := second.Close(testutil.Context(t)); closeErr != nil {
+ t.Fatalf("Close(second) error = %v", closeErr)
+ }
+ })
+
+ assertTablesPresent(t, second.db, "resource_records", "resource_source_state")
+ assertIndexesPresent(
+ t,
+ second.db,
+ "resource_records",
+ "idx_resource_kind",
+ "idx_resource_scope",
+ "idx_resource_owner",
+ "idx_resource_source",
+ )
+}
diff --git a/internal/store/globaldb/global_db_resources_test.go b/internal/store/globaldb/global_db_resources_test.go
new file mode 100644
index 000000000..49a316e33
--- /dev/null
+++ b/internal/store/globaldb/global_db_resources_test.go
@@ -0,0 +1,41 @@
+package globaldb
+
+import "testing"
+
+func TestOpenGlobalDBCreatesResourceTablesAndIndexes(t *testing.T) {
+ t.Parallel()
+
+ globalDB := openTestGlobalDB(t)
+
+ assertTablesPresent(t, globalDB.db, "resource_records", "resource_source_state")
+ assertTableColumns(t, globalDB.db, "resource_records", []string{
+ "kind",
+ "id",
+ "version",
+ "scope_kind",
+ "scope_id",
+ "owner_kind",
+ "owner_id",
+ "source_kind",
+ "source_id",
+ "spec_json",
+ "created_at",
+ "updated_at",
+ })
+ assertTableColumns(t, globalDB.db, "resource_source_state", []string{
+ "source_kind",
+ "source_id",
+ "session_nonce",
+ "last_snapshot_version",
+ "updated_at",
+ })
+ assertIndexesPresent(
+ t,
+ globalDB.db,
+ "resource_records",
+ "idx_resource_kind",
+ "idx_resource_scope",
+ "idx_resource_owner",
+ "idx_resource_source",
+ )
+}
diff --git a/internal/store/globaldb/global_db_session.go b/internal/store/globaldb/global_db_session.go
index 04de24ed5..a59aefeb7 100644
--- a/internal/store/globaldb/global_db_session.go
+++ b/internal/store/globaldb/global_db_session.go
@@ -72,7 +72,11 @@ func (g *GlobalDB) ListSessions(ctx context.Context, query store.SessionListQuer
}
sqlQuery := `SELECT id, name, agent_name, workspace_id, channel, session_type,
- state, acp_session_id, stop_reason, stop_detail, created_at, updated_at
+ state, acp_session_id, stop_reason, stop_detail,
+ environment_id, environment_backend, environment_profile, environment_instance_id,
+ environment_state, environment_provider_state_json,
+ environment_last_sync_at, environment_last_sync_error,
+ created_at, updated_at
FROM sessions`
where, args := store.BuildClauses(
store.StringClause("state", query.State),
@@ -185,8 +189,11 @@ func (g *GlobalDB) registerSession(ctx context.Context, exec sqlExecutor, sessio
ctx,
`INSERT INTO sessions (
id, name, agent_name, workspace_id, session_type, channel, state,
- acp_session_id, stop_reason, stop_detail, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ acp_session_id, stop_reason, stop_detail,
+ environment_id, environment_backend, environment_profile, environment_instance_id,
+ environment_state, environment_provider_state_json,
+ environment_last_sync_at, environment_last_sync_error, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
agent_name = excluded.agent_name,
@@ -197,6 +204,14 @@ func (g *GlobalDB) registerSession(ctx context.Context, exec sqlExecutor, sessio
acp_session_id = excluded.acp_session_id,
stop_reason = excluded.stop_reason,
stop_detail = excluded.stop_detail,
+ environment_id = excluded.environment_id,
+ environment_backend = excluded.environment_backend,
+ environment_profile = excluded.environment_profile,
+ environment_instance_id = excluded.environment_instance_id,
+ environment_state = excluded.environment_state,
+ environment_provider_state_json = excluded.environment_provider_state_json,
+ environment_last_sync_at = excluded.environment_last_sync_at,
+ environment_last_sync_error = excluded.environment_last_sync_error,
updated_at = excluded.updated_at`,
session.ID,
store.NullableString(session.Name),
@@ -208,6 +223,14 @@ func (g *GlobalDB) registerSession(ctx context.Context, exec sqlExecutor, sessio
store.NullableStringPointer(session.ACPSessionID),
store.NullableString(string(session.StopReason)),
store.NullableString(session.StopDetail),
+ sessionEnvironmentID(session.Environment),
+ sessionEnvironmentBackend(session.Environment),
+ sessionEnvironmentProfile(session.Environment),
+ sessionEnvironmentInstanceID(session.Environment),
+ sessionEnvironmentState(session.Environment),
+ sessionEnvironmentProviderStateJSON(session.Environment),
+ sessionEnvironmentLastSyncAt(session.Environment),
+ sessionEnvironmentLastSyncError(session.Environment),
store.FormatTimestamp(session.CreatedAt),
store.FormatTimestamp(session.UpdatedAt),
)
@@ -243,61 +266,66 @@ type sqlExecutor interface {
}
func buildUpdateSessionStateStatement(update store.SessionStateUpdate, updatedAt time.Time) (string, []any) {
+ assignments := []string{"state = ?"}
args := []any{update.State}
- switch {
- case update.ACPSessionID != nil && update.StopReasonSet:
- args = append(
- args,
- store.NullableStringPointer(update.ACPSessionID),
- store.NullableStringPointer(update.StopReason),
- store.NullableString(update.StopDetail),
- store.FormatTimestamp(updatedAt),
- update.ID,
- )
- return `UPDATE sessions
- SET state = ?, acp_session_id = ?, stop_reason = ?, stop_detail = ?, updated_at = ?
- WHERE id = ?`, args
- case update.ACPSessionID != nil:
- args = append(
- args,
- store.NullableStringPointer(update.ACPSessionID),
- store.FormatTimestamp(updatedAt),
- update.ID,
+ if update.ACPSessionID != nil {
+ assignments = append(assignments, "acp_session_id = ?")
+ args = append(args, store.NullableStringPointer(update.ACPSessionID))
+ }
+ if update.StopReasonSet {
+ assignments = append(assignments, "stop_reason = ?", "stop_detail = ?")
+ args = append(args, store.NullableStringPointer(update.StopReason), store.NullableString(update.StopDetail))
+ }
+ if update.Environment != nil {
+ assignments = append(
+ assignments,
+ "environment_id = ?",
+ "environment_backend = ?",
+ "environment_profile = ?",
+ "environment_instance_id = ?",
+ "environment_state = ?",
+ "environment_provider_state_json = ?",
+ "environment_last_sync_at = ?",
+ "environment_last_sync_error = ?",
)
- return `UPDATE sessions
- SET state = ?, acp_session_id = ?, updated_at = ?
- WHERE id = ?`, args
- case update.StopReasonSet:
args = append(
args,
- store.NullableStringPointer(update.StopReason),
- store.NullableString(update.StopDetail),
- store.FormatTimestamp(updatedAt),
- update.ID,
+ sessionEnvironmentID(update.Environment),
+ sessionEnvironmentBackend(update.Environment),
+ sessionEnvironmentProfile(update.Environment),
+ sessionEnvironmentInstanceID(update.Environment),
+ sessionEnvironmentState(update.Environment),
+ sessionEnvironmentProviderStateJSON(update.Environment),
+ sessionEnvironmentLastSyncAt(update.Environment),
+ sessionEnvironmentLastSyncError(update.Environment),
)
- return `UPDATE sessions
- SET state = ?, stop_reason = ?, stop_detail = ?, updated_at = ?
- WHERE id = ?`, args
- default:
- args = append(args, store.FormatTimestamp(updatedAt), update.ID)
- return `UPDATE sessions
- SET state = ?, updated_at = ?
- WHERE id = ?`, args
}
+
+ assignments = append(assignments, "updated_at = ?")
+ args = append(args, store.FormatTimestamp(updatedAt), update.ID)
+ return fmt.Sprintf("UPDATE sessions SET %s WHERE id = ?", strings.Join(assignments, ", ")), args
}
func scanSessionInfo(scanner rowScanner) (store.SessionInfo, error) {
var (
- session store.SessionInfo
- name sql.NullString
- channel string
- sessionType string
- acpSessionID sql.NullString
- stopReason sql.NullString
- stopDetail sql.NullString
- createdAtRaw string
- updatedAtRaw string
+ session store.SessionInfo
+ name sql.NullString
+ channel string
+ sessionType string
+ acpSessionID sql.NullString
+ stopReason sql.NullString
+ stopDetail sql.NullString
+ envID string
+ envBackend string
+ envProfile string
+ envInstance string
+ envState string
+ envProviderStateJSON string
+ envLastSyncAt sql.NullString
+ envLastSyncError string
+ createdAtRaw string
+ updatedAtRaw string
)
if err := scanner.Scan(
&session.ID,
@@ -310,6 +338,14 @@ func scanSessionInfo(scanner rowScanner) (store.SessionInfo, error) {
&acpSessionID,
&stopReason,
&stopDetail,
+ &envID,
+ &envBackend,
+ &envProfile,
+ &envInstance,
+ &envState,
+ &envProviderStateJSON,
+ &envLastSyncAt,
+ &envLastSyncError,
&createdAtRaw,
&updatedAtRaw,
); err != nil {
@@ -328,6 +364,16 @@ func scanSessionInfo(scanner rowScanner) (store.SessionInfo, error) {
if detail := store.NullString(stopDetail); detail != nil {
session.StopDetail = *detail
}
+ session.Environment = scanSessionEnvironment(
+ envID,
+ envBackend,
+ envProfile,
+ envInstance,
+ envState,
+ envProviderStateJSON,
+ envLastSyncAt,
+ envLastSyncError,
+ )
createdAt, err := store.ParseTimestamp(createdAtRaw)
if err != nil {
@@ -343,6 +389,110 @@ func scanSessionInfo(scanner rowScanner) (store.SessionInfo, error) {
return session, nil
}
+func scanSessionEnvironment(
+ environmentID string,
+ backend string,
+ profile string,
+ instanceID string,
+ state string,
+ providerStateJSON string,
+ lastSyncAt sql.NullString,
+ lastSyncError string,
+) *store.SessionEnvironmentMeta {
+ environmentID = strings.TrimSpace(environmentID)
+ backend = strings.TrimSpace(backend)
+ profile = strings.TrimSpace(profile)
+ instanceID = strings.TrimSpace(instanceID)
+ state = strings.TrimSpace(state)
+ providerStateJSON = strings.TrimSpace(providerStateJSON)
+ if environmentID == "" &&
+ backend == "" &&
+ profile == "" &&
+ instanceID == "" &&
+ state == "" &&
+ providerStateJSON == "" {
+ return nil
+ }
+
+ meta := &store.SessionEnvironmentMeta{
+ EnvironmentID: environmentID,
+ Backend: backend,
+ Profile: profile,
+ InstanceID: instanceID,
+ State: state,
+ }
+ if providerStateJSON != "" {
+ meta.ProviderState = []byte(providerStateJSON)
+ }
+ if lastSyncAt.Valid && strings.TrimSpace(lastSyncAt.String) != "" {
+ if parsed, err := store.ParseTimestamp(lastSyncAt.String); err == nil {
+ meta.LastSyncAt = &parsed
+ }
+ }
+ meta.LastSyncError = strings.TrimSpace(lastSyncError)
+ return meta
+}
+
+func sessionEnvironmentID(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.EnvironmentID)
+}
+
+func sessionEnvironmentBackend(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return "local"
+ }
+ backend := strings.TrimSpace(meta.Backend)
+ if backend == "" {
+ return "local"
+ }
+ return backend
+}
+
+func sessionEnvironmentProfile(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.Profile)
+}
+
+func sessionEnvironmentInstanceID(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.InstanceID)
+}
+
+func sessionEnvironmentState(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.State)
+}
+
+func sessionEnvironmentProviderStateJSON(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil || len(meta.ProviderState) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(string(meta.ProviderState))
+}
+
+func sessionEnvironmentLastSyncAt(meta *store.SessionEnvironmentMeta) any {
+ if meta == nil || meta.LastSyncAt == nil || meta.LastSyncAt.IsZero() {
+ return nil
+ }
+ return store.FormatTimestamp(*meta.LastSyncAt)
+}
+
+func sessionEnvironmentLastSyncError(meta *store.SessionEnvironmentMeta) string {
+ if meta == nil {
+ return ""
+ }
+ return strings.TrimSpace(meta.LastSyncError)
+}
+
type rowScanner interface {
Scan(dest ...any) error
}
diff --git a/internal/store/globaldb/global_db_session_test.go b/internal/store/globaldb/global_db_session_test.go
index 34d4a650d..d04dc7817 100644
--- a/internal/store/globaldb/global_db_session_test.go
+++ b/internal/store/globaldb/global_db_session_test.go
@@ -25,8 +25,17 @@ func TestScanSessionInfoReadsStopFields(t *testing.T) {
'acp-123',
'timeout',
'deadline exceeded',
+ 'env-scan',
+ 'local',
+ 'local',
+ 'instance-scan',
+ 'prepared',
+ '{"local":true}',
+ ?,
+ 'sync failed',
?,
?`,
+ formatTimestamp(time.Date(2026, 4, 3, 12, 4, 0, 0, time.UTC)),
formatTimestamp(time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC)),
formatTimestamp(time.Date(2026, 4, 3, 12, 5, 0, 0, time.UTC)),
)
@@ -47,6 +56,15 @@ func TestScanSessionInfoReadsStopFields(t *testing.T) {
if info.ACPSessionID == nil || *info.ACPSessionID != "acp-123" {
t.Fatalf("info.ACPSessionID = %#v, want acp-123", info.ACPSessionID)
}
+ if info.Environment == nil {
+ t.Fatal("info.Environment = nil, want environment metadata")
+ }
+ if got, want := info.Environment.EnvironmentID, "env-scan"; got != want {
+ t.Fatalf("info.Environment.EnvironmentID = %q, want %q", got, want)
+ }
+ if got, want := info.Environment.LastSyncError, "sync failed"; got != want {
+ t.Fatalf("info.Environment.LastSyncError = %q, want %q", got, want)
+ }
}
func TestScanSessionInfoHandlesNullStopReason(t *testing.T) {
@@ -65,6 +83,14 @@ func TestScanSessionInfoHandlesNullStopReason(t *testing.T) {
NULL,
NULL,
NULL,
+ '',
+ 'local',
+ '',
+ '',
+ '',
+ '',
+ NULL,
+ '',
?,
?`,
formatTimestamp(time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC)),
diff --git a/internal/store/globaldb/global_db_task_graph_audit_test.go b/internal/store/globaldb/global_db_task_graph_audit_test.go
index fd95c5f8b..0166d7650 100644
--- a/internal/store/globaldb/global_db_task_graph_audit_test.go
+++ b/internal/store/globaldb/global_db_task_graph_audit_test.go
@@ -43,6 +43,15 @@ func TestGlobalDBTaskDependencyRoundTripAndDelete(t *testing.T) {
}
assertTaskDependencyEqual(t, dependencies[0], rootDependsOnMiddle)
+ dependents, err := globalDB.ListDependents(testutil.Context(t), middleTask.ID)
+ if err != nil {
+ t.Fatalf("ListDependents() error = %v", err)
+ }
+ if got, want := len(dependents), 1; got != want {
+ t.Fatalf("len(ListDependents()) = %d, want %d", got, want)
+ }
+ assertTaskDependencyEqual(t, dependents[0], rootDependsOnMiddle)
+
count, err := globalDB.CountDependencies(testutil.Context(t), rootTask.ID)
if err != nil {
t.Fatalf("CountDependencies() error = %v", err)
diff --git a/internal/store/globaldb/global_db_task_test.go b/internal/store/globaldb/global_db_task_test.go
index 092967622..066952de0 100644
--- a/internal/store/globaldb/global_db_task_test.go
+++ b/internal/store/globaldb/global_db_task_test.go
@@ -174,6 +174,21 @@ func TestGlobalDBTaskRoundTripPreservesNullableFields(t *testing.T) {
}
assertTaskEqual(t, gotChild, child)
+ child.Title = "Updated child"
+ child.Description = "Updated description"
+ child.Status = taskpkg.TaskStatusInProgress
+ child.Owner = ownershipForTest(taskpkg.OwnerKindAgentSession, "sess-1")
+ child.Metadata = json.RawMessage(`{"kind":"updated"}`)
+ child.UpdatedAt = child.UpdatedAt.Add(2 * time.Minute)
+ if err := globalDB.UpdateTask(testutil.Context(t), child); err != nil {
+ t.Fatalf("UpdateTask(child) error = %v", err)
+ }
+ gotChild, err = globalDB.GetTask(testutil.Context(t), child.ID)
+ if err != nil {
+ t.Fatalf("GetTask(updated child) error = %v", err)
+ }
+ assertTaskEqual(t, gotChild, child)
+
summaries, err := globalDB.ListTasks(testutil.Context(t), taskpkg.Query{ParentTaskID: parent.ID})
if err != nil {
t.Fatalf("ListTasks(parent filter) error = %v", err)
diff --git a/internal/store/globaldb/global_db_test.go b/internal/store/globaldb/global_db_test.go
index fa8980736..1eff9748e 100644
--- a/internal/store/globaldb/global_db_test.go
+++ b/internal/store/globaldb/global_db_test.go
@@ -440,6 +440,7 @@ func TestGlobalDBWorkspaceCRUDAndLookups(t *testing.T) {
AdditionalDirs: []string{filepath.Join(rootDir, "a"), "", filepath.Join(rootDir, "b")},
Name: "alpha",
DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
@@ -457,6 +458,7 @@ func TestGlobalDBWorkspaceCRUDAndLookups(t *testing.T) {
AdditionalDirs: []string{filepath.Join(rootDir, "a"), filepath.Join(rootDir, "b")},
Name: "alpha",
DefaultAgent: "coder",
+ EnvironmentRef: "daytona-dev",
CreatedAt: createdAt,
UpdatedAt: createdAt,
})
@@ -476,6 +478,7 @@ func TestGlobalDBWorkspaceCRUDAndLookups(t *testing.T) {
updated := byID
updated.Name = "beta"
updated.DefaultAgent = "reviewer"
+ updated.EnvironmentRef = "local-dev"
updated.AdditionalDirs = []string{filepath.Join(rootDir, "tools")}
updated.UpdatedAt = createdAt.Add(5 * time.Minute)
if err := globalDB.UpdateWorkspace(testutil.Context(t), updated); err != nil {
@@ -868,8 +871,17 @@ func TestGlobalDBRegisterAndListSessionsUseWorkspaceID(t *testing.T) {
WorkspaceID: workspaceID,
Channel: "builders",
State: "active",
- CreatedAt: time.Date(2026, 4, 3, 13, 0, 0, 0, time.UTC),
- UpdatedAt: time.Date(2026, 4, 3, 13, 0, 0, 0, time.UTC),
+ Environment: &store.SessionEnvironmentMeta{
+ EnvironmentID: "env-workspace-id",
+ Backend: "local",
+ Profile: "local",
+ State: "prepared",
+ InstanceID: "instance-workspace-id",
+ ProviderState: []byte(`{"provider":true}`),
+ LastSyncError: "last sync failed",
+ },
+ CreatedAt: time.Date(2026, 4, 3, 13, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 3, 13, 0, 0, 0, time.UTC),
}
if err := globalDB.RegisterSession(testutil.Context(t), session); err != nil {
t.Fatalf("RegisterSession() error = %v", err)
@@ -888,6 +900,18 @@ func TestGlobalDBRegisterAndListSessionsUseWorkspaceID(t *testing.T) {
if got, want := sessions[0].Channel, "builders"; got != want {
t.Fatalf("sessions[0].Channel = %q, want %q", got, want)
}
+ if sessions[0].Environment == nil {
+ t.Fatal("sessions[0].Environment = nil, want environment metadata")
+ }
+ if got, want := sessions[0].Environment.EnvironmentID, "env-workspace-id"; got != want {
+ t.Fatalf("sessions[0].Environment.EnvironmentID = %q, want %q", got, want)
+ }
+ if got, want := sessions[0].Environment.InstanceID, "instance-workspace-id"; got != want {
+ t.Fatalf("sessions[0].Environment.InstanceID = %q, want %q", got, want)
+ }
+ if got, want := sessions[0].Environment.LastSyncError, "last sync failed"; got != want {
+ t.Fatalf("sessions[0].Environment.LastSyncError = %q, want %q", got, want)
+ }
assertTableColumns(
t,
@@ -904,6 +928,14 @@ func TestGlobalDBRegisterAndListSessionsUseWorkspaceID(t *testing.T) {
"acp_session_id",
"stop_reason",
"stop_detail",
+ "environment_id",
+ "environment_backend",
+ "environment_profile",
+ "environment_instance_id",
+ "environment_state",
+ "environment_provider_state_json",
+ "environment_last_sync_at",
+ "environment_last_sync_error",
"created_at",
"updated_at",
},
@@ -1046,6 +1078,14 @@ func TestOpenGlobalDBMigratesLegacyWorkspaceColumn(t *testing.T) {
"acp_session_id",
"stop_reason",
"stop_detail",
+ "environment_id",
+ "environment_backend",
+ "environment_profile",
+ "environment_instance_id",
+ "environment_state",
+ "environment_provider_state_json",
+ "environment_last_sync_at",
+ "environment_last_sync_error",
"created_at",
"updated_at",
},
@@ -1054,7 +1094,7 @@ func TestOpenGlobalDBMigratesLegacyWorkspaceColumn(t *testing.T) {
t,
globalDB.db,
"workspaces",
- []string{"id", "root_dir", "add_dirs", "name", "default_agent", "created_at", "updated_at"},
+ []string{"id", "root_dir", "add_dirs", "name", "default_agent", "environment_ref", "created_at", "updated_at"},
)
workspaces, err := globalDB.ListWorkspaces(ctx)
@@ -1669,8 +1709,22 @@ func TestOpenGlobalDBAddsStopColumnsToCurrentSessionSchema(t *testing.T) {
"stop_reason",
"stop_detail",
"channel",
+ "environment_id",
+ "environment_backend",
+ "environment_profile",
+ "environment_instance_id",
+ "environment_state",
+ "environment_provider_state_json",
+ "environment_last_sync_at",
+ "environment_last_sync_error",
},
)
+ assertTableColumns(
+ t,
+ globalDB.db,
+ "workspaces",
+ []string{"id", "root_dir", "add_dirs", "name", "default_agent", "created_at", "updated_at", "environment_ref"},
+ )
sessions, err := globalDB.ListSessions(ctx, SessionListQuery{})
if err != nil {
@@ -1794,6 +1848,7 @@ func assertWorkspaceEqual(t *testing.T, got aghworkspace.Workspace, want aghwork
got.RootDir != want.RootDir ||
got.Name != want.Name ||
got.DefaultAgent != want.DefaultAgent ||
+ got.EnvironmentRef != want.EnvironmentRef ||
!got.CreatedAt.Equal(want.CreatedAt) ||
!got.UpdatedAt.Equal(want.UpdatedAt) ||
!testutil.EqualStringSlices(got.AdditionalDirs, want.AdditionalDirs) {
diff --git a/internal/store/globaldb/global_db_workspace.go b/internal/store/globaldb/global_db_workspace.go
index 7e1295841..5c06adab3 100644
--- a/internal/store/globaldb/global_db_workspace.go
+++ b/internal/store/globaldb/global_db_workspace.go
@@ -26,13 +26,14 @@ func (g *GlobalDB) InsertWorkspace(ctx context.Context, ws aghworkspace.Workspac
if _, err := g.db.ExecContext(
ctx,
`INSERT INTO workspaces (
- id, root_dir, add_dirs, name, default_agent, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
normalized.ID,
normalized.RootDir,
addDirsJSON,
normalized.Name,
store.NullableString(normalized.DefaultAgent),
+ normalized.EnvironmentRef,
store.FormatTimestamp(normalized.CreatedAt),
store.FormatTimestamp(normalized.UpdatedAt),
); err != nil {
@@ -56,12 +57,13 @@ func (g *GlobalDB) UpdateWorkspace(ctx context.Context, ws aghworkspace.Workspac
result, err := g.db.ExecContext(
ctx,
`UPDATE workspaces
- SET root_dir = ?, add_dirs = ?, name = ?, default_agent = ?, updated_at = ?
+ SET root_dir = ?, add_dirs = ?, name = ?, default_agent = ?, environment_ref = ?, updated_at = ?
WHERE id = ?`,
normalized.RootDir,
addDirsJSON,
normalized.Name,
store.NullableString(normalized.DefaultAgent),
+ normalized.EnvironmentRef,
store.FormatTimestamp(normalized.UpdatedAt),
normalized.ID,
)
@@ -120,7 +122,8 @@ func (g *GlobalDB) GetWorkspace(ctx context.Context, id string) (aghworkspace.Wo
return g.getWorkspaceByQuery(
ctx,
- `SELECT id, root_dir, add_dirs, name, default_agent, created_at, updated_at FROM workspaces WHERE id = ?`,
+ `SELECT id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at
+ FROM workspaces WHERE id = ?`,
trimmedID,
)
}
@@ -138,7 +141,8 @@ func (g *GlobalDB) GetWorkspaceByPath(ctx context.Context, rootDir string) (aghw
return g.getWorkspaceByQuery(
ctx,
- `SELECT id, root_dir, add_dirs, name, default_agent, created_at, updated_at FROM workspaces WHERE root_dir = ?`,
+ `SELECT id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at
+ FROM workspaces WHERE root_dir = ?`,
trimmedRoot,
)
}
@@ -156,7 +160,8 @@ func (g *GlobalDB) GetWorkspaceByName(ctx context.Context, name string) (aghwork
return g.getWorkspaceByQuery(
ctx,
- `SELECT id, root_dir, add_dirs, name, default_agent, created_at, updated_at FROM workspaces WHERE name = ?`,
+ `SELECT id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at
+ FROM workspaces WHERE name = ?`,
trimmedName,
)
}
@@ -169,7 +174,7 @@ func (g *GlobalDB) ListWorkspaces(ctx context.Context) ([]aghworkspace.Workspace
rows, err := g.db.QueryContext(
ctx,
- `SELECT id, root_dir, add_dirs, name, default_agent, created_at, updated_at
+ `SELECT id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at
FROM workspaces
ORDER BY name ASC, id ASC`,
)
@@ -244,11 +249,12 @@ func (g *GlobalDB) normalizeWorkspaceForUpdate(ws aghworkspace.Workspace) (aghwo
func scanWorkspace(scanner rowScanner) (aghworkspace.Workspace, error) {
var (
- ws aghworkspace.Workspace
- addDirsRaw string
- defaultAgent sql.NullString
- createdAtRaw string
- updatedAtRaw string
+ ws aghworkspace.Workspace
+ addDirsRaw string
+ defaultAgent sql.NullString
+ environmentRef string
+ createdAtRaw string
+ updatedAtRaw string
)
if err := scanner.Scan(
&ws.ID,
@@ -256,6 +262,7 @@ func scanWorkspace(scanner rowScanner) (aghworkspace.Workspace, error) {
&addDirsRaw,
&ws.Name,
&defaultAgent,
+ &environmentRef,
&createdAtRaw,
&updatedAtRaw,
); err != nil {
@@ -270,6 +277,7 @@ func scanWorkspace(scanner rowScanner) (aghworkspace.Workspace, error) {
if defaultAgent.Valid {
ws.DefaultAgent = strings.TrimSpace(defaultAgent.String)
}
+ ws.EnvironmentRef = strings.TrimSpace(environmentRef)
createdAt, err := store.ParseTimestamp(createdAtRaw)
if err != nil {
@@ -291,6 +299,7 @@ func normalizeWorkspaceRecord(ws aghworkspace.Workspace) (aghworkspace.Workspace
normalized.RootDir = strings.TrimSpace(normalized.RootDir)
normalized.Name = strings.TrimSpace(normalized.Name)
normalized.DefaultAgent = strings.TrimSpace(normalized.DefaultAgent)
+ normalized.EnvironmentRef = strings.TrimSpace(normalized.EnvironmentRef)
normalized.AdditionalDirs = compactStrings(normalized.AdditionalDirs)
switch {
diff --git a/internal/store/globaldb/migrate_workspace.go b/internal/store/globaldb/migrate_workspace.go
index 8713b5ca0..cf4de5a55 100644
--- a/internal/store/globaldb/migrate_workspace.go
+++ b/internal/store/globaldb/migrate_workspace.go
@@ -63,10 +63,9 @@ func migrateGlobalSchema(ctx context.Context, db *sql.DB) error {
if err := migrateBridgeInstanceColumns(ctx, db); err != nil {
return err
}
- if err := migrateBundleActivationColumns(ctx, db); err != nil {
+ if err := migrateWorkspaceColumns(ctx, db); err != nil {
return err
}
-
hasSessions, err := tableExists(ctx, db, "sessions")
if err != nil {
return err
@@ -94,6 +93,32 @@ func migrateGlobalSchema(ctx context.Context, db *sql.DB) error {
return migrateNetworkAuditTable(ctx, db)
}
+func migrateWorkspaceColumns(ctx context.Context, db *sql.DB) error {
+ exists, err := tableExists(ctx, db, "workspaces")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+
+ columns, err := tableColumns(ctx, db, "workspaces")
+ if err != nil {
+ return err
+ }
+ if _, ok := columns["environment_ref"]; ok {
+ return nil
+ }
+
+ if _, err := db.ExecContext(
+ ctx,
+ `ALTER TABLE workspaces ADD COLUMN environment_ref TEXT NOT NULL DEFAULT ''`,
+ ); err != nil {
+ return fmt.Errorf("store: add workspaces.environment_ref column: %w", err)
+ }
+ return nil
+}
+
func migrateLegacyGlobalSessions(ctx context.Context, db *sql.DB) (err error) {
conn, err := db.Conn(ctx)
if err != nil {
@@ -299,6 +324,42 @@ func migrateSessionColumns(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("store: add sessions.channel column: %w", err)
}
}
+ sessionEnvironmentColumns := []struct {
+ name string
+ sql string
+ }{
+ {name: "environment_id", sql: `ALTER TABLE sessions ADD COLUMN environment_id TEXT NOT NULL DEFAULT ''`},
+ {
+ name: "environment_backend",
+ sql: `ALTER TABLE sessions ADD COLUMN environment_backend TEXT NOT NULL DEFAULT 'local'`,
+ },
+ {
+ name: "environment_profile",
+ sql: `ALTER TABLE sessions ADD COLUMN environment_profile TEXT NOT NULL DEFAULT ''`,
+ },
+ {
+ name: "environment_instance_id",
+ sql: `ALTER TABLE sessions ADD COLUMN environment_instance_id TEXT NOT NULL DEFAULT ''`,
+ },
+ {name: "environment_state", sql: `ALTER TABLE sessions ADD COLUMN environment_state TEXT NOT NULL DEFAULT ''`},
+ {
+ name: "environment_provider_state_json",
+ sql: `ALTER TABLE sessions ADD COLUMN environment_provider_state_json TEXT NOT NULL DEFAULT ''`,
+ },
+ {name: "environment_last_sync_at", sql: `ALTER TABLE sessions ADD COLUMN environment_last_sync_at TEXT`},
+ {
+ name: "environment_last_sync_error",
+ sql: `ALTER TABLE sessions ADD COLUMN environment_last_sync_error TEXT NOT NULL DEFAULT ''`,
+ },
+ }
+ for _, column := range sessionEnvironmentColumns {
+ if _, ok := columns[column.name]; ok {
+ continue
+ }
+ if _, err := db.ExecContext(ctx, column.sql); err != nil {
+ return fmt.Errorf("store: add sessions.%s column: %w", column.name, err)
+ }
+ }
return nil
}
@@ -329,29 +390,6 @@ func migrateBridgeColumns(ctx context.Context, db *sql.DB) error {
return nil
}
-func migrateBundleActivationColumns(ctx context.Context, db *sql.DB) error {
- exists, err := tableExists(ctx, db, "bundle_activations")
- if err != nil {
- return err
- }
- if !exists {
- return nil
- }
-
- columns, err := tableColumns(ctx, db, "bundle_activations")
- if err != nil {
- return err
- }
- if _, ok := columns["spec_content_hash"]; ok {
- return nil
- }
-
- if _, err := db.ExecContext(ctx, `ALTER TABLE bundle_activations ADD COLUMN spec_content_hash TEXT`); err != nil {
- return fmt.Errorf("store: add bundle_activations.spec_content_hash column: %w", err)
- }
- return nil
-}
-
func migrateNetworkAuditTable(ctx context.Context, db *sql.DB) (err error) {
exists, err := tableExists(ctx, db, "network_audit_log")
if err != nil {
@@ -525,8 +563,8 @@ func ensureMigratedWorkspaces(
workspaceID := store.NewID("ws")
if _, err := tx.ExecContext(
ctx,
- `INSERT INTO workspaces (id, root_dir, add_dirs, name, default_agent, created_at, updated_at)
- VALUES (?, ?, '[]', ?, '', ?, ?)`,
+ `INSERT INTO workspaces (id, root_dir, add_dirs, name, default_agent, environment_ref, created_at, updated_at)
+ VALUES (?, ?, '[]', ?, '', '', ?, ?)`,
workspaceID,
rootDir,
name,
@@ -556,6 +594,14 @@ func createMigratedGlobalTables(ctx context.Context, tx *sql.Tx) error {
acp_session_id TEXT,
stop_reason TEXT,
stop_detail TEXT,
+ environment_id TEXT NOT NULL DEFAULT '',
+ environment_backend TEXT NOT NULL DEFAULT 'local',
+ environment_profile TEXT NOT NULL DEFAULT '',
+ environment_instance_id TEXT NOT NULL DEFAULT '',
+ environment_state TEXT NOT NULL DEFAULT '',
+ environment_provider_state_json TEXT NOT NULL DEFAULT '',
+ environment_last_sync_at TEXT,
+ environment_last_sync_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
@@ -615,8 +661,9 @@ func copyMigratedSessions(
if _, err := tx.ExecContext(
ctx,
`INSERT INTO sessions_new (
- id, name, agent_name, workspace_id, session_type, channel, state, acp_session_id, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ id, name, agent_name, workspace_id, session_type, channel, state, acp_session_id,
+ environment_backend, environment_profile, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
row.ID,
nullStringValue(row.Name),
row.AgentName,
@@ -625,6 +672,8 @@ func copyMigratedSessions(
"",
row.State,
nullStringValue(row.ACPSessionID),
+ "local",
+ "local",
row.CreatedAt,
row.UpdatedAt,
); err != nil {
diff --git a/internal/store/meta_test.go b/internal/store/meta_test.go
index 779a74198..d357431f3 100644
--- a/internal/store/meta_test.go
+++ b/internal/store/meta_test.go
@@ -1,6 +1,7 @@
package store
import (
+ "encoding/json"
"os"
"path/filepath"
"sync"
@@ -23,8 +24,21 @@ func TestWriteSessionMetaAndReadBack(t *testing.T) {
State: "stopped",
StopReason: &stopReason,
StopDetail: "hook denied continuation",
- CreatedAt: time.Date(2026, 4, 3, 17, 0, 0, 0, time.UTC),
- UpdatedAt: time.Date(2026, 4, 3, 17, 1, 0, 0, time.UTC),
+ Environment: &SessionEnvironmentMeta{
+ EnvironmentID: "env-123",
+ Backend: "daytona",
+ Profile: "daytona-dev",
+ State: "ready",
+ InstanceID: "sandbox-123",
+ RuntimeRootDir: "/home/daytona/workspace",
+ RuntimeAdditionalDirs: []string{"/home/daytona/shared"},
+ ProviderState: json.RawMessage(`{"sandbox_id":"sandbox-123"}`),
+ SSHAccessExpiresAt: timePtr(time.Date(2026, 4, 3, 18, 0, 0, 0, time.UTC)),
+ LastSyncAt: timePtr(time.Date(2026, 4, 3, 17, 59, 0, 0, time.UTC)),
+ LastSyncError: "sync warning",
+ },
+ CreatedAt: time.Date(2026, 4, 3, 17, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 4, 3, 17, 1, 0, 0, time.UTC),
}
if err := WriteSessionMeta(path, meta); err != nil {
@@ -50,6 +64,38 @@ func TestWriteSessionMetaAndReadBack(t *testing.T) {
if *readBack.StopReason != *meta.StopReason {
t.Fatalf("ReadSessionMeta().StopReason = %q, want %q", *readBack.StopReason, *meta.StopReason)
}
+ if readBack.Environment == nil {
+ t.Fatal("ReadSessionMeta().Environment = nil, want metadata")
+ }
+ if readBack.Environment.EnvironmentID != "env-123" ||
+ readBack.Environment.State != "ready" ||
+ readBack.Environment.InstanceID != "sandbox-123" ||
+ readBack.Environment.LastSyncError != "sync warning" {
+ t.Fatalf("ReadSessionMeta().Environment = %#v, want persisted environment metadata", readBack.Environment)
+ }
+ var providerState struct {
+ SandboxID string `json:"sandbox_id"`
+ }
+ if err := json.Unmarshal(readBack.Environment.ProviderState, &providerState); err != nil {
+ t.Fatalf("json.Unmarshal(ProviderState) error = %v", err)
+ }
+ if providerState.SandboxID != "sandbox-123" {
+ t.Fatalf("ProviderState sandbox_id = %q, want sandbox-123", providerState.SandboxID)
+ }
+ if readBack.Environment.SSHAccessExpiresAt == nil ||
+ !readBack.Environment.SSHAccessExpiresAt.Equal(*meta.Environment.SSHAccessExpiresAt) {
+ t.Fatalf("SSHAccessExpiresAt = %#v, want %#v",
+ readBack.Environment.SSHAccessExpiresAt,
+ meta.Environment.SSHAccessExpiresAt,
+ )
+ }
+ if readBack.Environment.LastSyncAt == nil ||
+ !readBack.Environment.LastSyncAt.Equal(*meta.Environment.LastSyncAt) {
+ t.Fatalf("LastSyncAt = %#v, want %#v",
+ readBack.Environment.LastSyncAt,
+ meta.Environment.LastSyncAt,
+ )
+ }
}
func TestWriteSessionMetaConcurrentWritesDoNotCorruptFile(t *testing.T) {
@@ -138,3 +184,7 @@ func TestReadSessionMetaLegacyStopFieldsOmitted(t *testing.T) {
}
})
}
+
+func timePtr(value time.Time) *time.Time {
+ return &value
+}
diff --git a/internal/store/store_helpers_test.go b/internal/store/store_helpers_test.go
index 8e7a7f8a0..494302ea9 100644
--- a/internal/store/store_helpers_test.go
+++ b/internal/store/store_helpers_test.go
@@ -258,6 +258,95 @@ func TestValidationHelpersAndPathUtilities(t *testing.T) {
},
wantError: true,
},
+ {
+ name: "token usage valid",
+ validate: func() error {
+ return (TokenUsage{TurnID: "turn-1"}).Validate()
+ },
+ },
+ {
+ name: "token usage invalid",
+ validate: func() error {
+ return (TokenUsage{}).Validate()
+ },
+ wantError: true,
+ },
+ {
+ name: "network audit entry valid",
+ validate: func() error {
+ return (NetworkAuditEntry{
+ SessionID: "sess-1",
+ Direction: "rejected",
+ Kind: "message",
+ Channel: "builders",
+ PeerFrom: "peer-a",
+ MessageID: "msg-1",
+ Reason: "policy",
+ Size: 0,
+ }).Validate()
+ },
+ },
+ {
+ name: "network audit entry invalid direction",
+ validate: func() error {
+ return (NetworkAuditEntry{
+ SessionID: "sess-1",
+ Direction: "replayed",
+ Kind: "message",
+ Channel: "builders",
+ PeerFrom: "peer-a",
+ MessageID: "msg-1",
+ }).Validate()
+ },
+ wantError: true,
+ },
+ {
+ name: "network audit entry rejected requires reason",
+ validate: func() error {
+ return (NetworkAuditEntry{
+ SessionID: "sess-1",
+ Direction: "rejected",
+ Kind: "message",
+ Channel: "builders",
+ PeerFrom: "peer-a",
+ MessageID: "msg-1",
+ }).Validate()
+ },
+ wantError: true,
+ },
+ {
+ name: "network audit query invalid",
+ validate: func() error {
+ return (NetworkAuditQuery{Limit: -1}).Validate()
+ },
+ wantError: true,
+ },
+ {
+ name: "network message entry valid",
+ validate: func() error {
+ return (NetworkMessageEntry{
+ MessageID: "msg-1",
+ Channel: "builders",
+ PeerFrom: "peer-a",
+ Kind: "agent.message",
+ Text: "hello",
+ }).Validate()
+ },
+ },
+ {
+ name: "network message entry invalid",
+ validate: func() error {
+ return (NetworkMessageEntry{MessageID: "msg-1"}).Validate()
+ },
+ wantError: true,
+ },
+ {
+ name: "network message query invalid",
+ validate: func() error {
+ return (NetworkMessageQuery{Limit: -1}).Validate()
+ },
+ wantError: true,
+ },
{
name: "session meta valid",
validate: func() error {
diff --git a/internal/store/types.go b/internal/store/types.go
index 37fa3ea86..ccba54a54 100644
--- a/internal/store/types.go
+++ b/internal/store/types.go
@@ -1,6 +1,7 @@
package store
import (
+ "encoding/json"
"fmt"
"strings"
"time"
@@ -148,6 +149,7 @@ type SessionInfo struct {
ACPSessionID *string
StopReason StopReason
StopDetail string
+ Environment *SessionEnvironmentMeta
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -189,6 +191,7 @@ type SessionStateUpdate struct {
StopReasonSet bool
StopReason *string
StopDetail string
+ Environment *SessionEnvironmentMeta
UpdatedAt time.Time
}
@@ -470,20 +473,36 @@ type ReconcileResult struct {
Orphaned []string
}
+// SessionEnvironmentMeta is the persisted runtime environment state for a session.
+type SessionEnvironmentMeta struct {
+ EnvironmentID string `json:"environment_id,omitempty"`
+ Backend string `json:"backend"`
+ Profile string `json:"profile,omitempty"`
+ State string `json:"state,omitempty"`
+ InstanceID string `json:"instance_id,omitempty"`
+ RuntimeRootDir string `json:"runtime_root_dir,omitempty"`
+ RuntimeAdditionalDirs []string `json:"runtime_additional_dirs,omitempty"`
+ ProviderState json.RawMessage `json:"provider_state,omitempty"`
+ SSHAccessExpiresAt *time.Time `json:"ssh_access_expires_at,omitempty"`
+ LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
+ LastSyncError string `json:"last_sync_error,omitempty"`
+}
+
// SessionMeta is the atomically-written session metadata document.
type SessionMeta struct {
- ID string `json:"id"`
- Name string `json:"name,omitempty"`
- AgentName string `json:"agent_name"`
- WorkspaceID string `json:"workspace_id,omitempty"`
- Channel string `json:"channel,omitempty"`
- SessionType string `json:"session_type,omitempty"`
- State string `json:"state"`
- StopReason *StopReason `json:"stop_reason,omitempty"`
- StopDetail string `json:"stop_detail,omitempty"`
- ACPSessionID *string `json:"acp_session_id,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ AgentName string `json:"agent_name"`
+ WorkspaceID string `json:"workspace_id,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ SessionType string `json:"session_type,omitempty"`
+ State string `json:"state"`
+ StopReason *StopReason `json:"stop_reason,omitempty"`
+ StopDetail string `json:"stop_detail,omitempty"`
+ ACPSessionID *string `json:"acp_session_id,omitempty"`
+ Environment *SessionEnvironmentMeta `json:"environment,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// Validate ensures the metadata file remains aligned with the session index schema.
diff --git a/internal/subprocess/handshake.go b/internal/subprocess/handshake.go
index acaa58497..3ec8104f0 100644
--- a/internal/subprocess/handshake.go
+++ b/internal/subprocess/handshake.go
@@ -10,6 +10,7 @@ import (
"github.com/pedronauck/agh/internal/bridges"
extensionprotocol "github.com/pedronauck/agh/internal/extension/protocol"
+ "github.com/pedronauck/agh/internal/resources"
)
// InitializeRequest is the AGH -> extension session contract request.
@@ -17,6 +18,7 @@ type InitializeRequest struct {
ProtocolVersion string `json:"protocol_version"`
SupportedProtocolVersion []string `json:"supported_protocol_versions"`
AGHVersion string `json:"agh_version"`
+ SessionNonce string `json:"session_nonce"`
Extension InitializeExtension `json:"extension"`
Capabilities InitializeCapabilities `json:"capabilities"`
Methods InitializeMethods `json:"methods"`
@@ -32,9 +34,11 @@ type InitializeExtension struct {
// InitializeCapabilities carries runtime-granted capabilities.
type InitializeCapabilities struct {
- Provides []string `json:"provides"`
- GrantedActions []extensionprotocol.HostAPIMethod `json:"granted_actions"`
- GrantedSecurity []string `json:"granted_security"`
+ Provides []string `json:"provides"`
+ GrantedActions []extensionprotocol.HostAPIMethod `json:"granted_actions"`
+ GrantedSecurity []string `json:"granted_security"`
+ GrantedResourceKinds []resources.ResourceKind `json:"granted_resource_kinds"`
+ GrantedResourceScopes []resources.ResourceScopeKind `json:"granted_resource_scopes"`
}
// InitializeMethods lists callable method families for the session.
@@ -108,8 +112,7 @@ type AcceptedCapabilities struct {
// InitializeSupports advertises optional protocol features.
type InitializeSupports struct {
- HealthCheck bool `json:"health_check"`
- ProvideTools bool `json:"provide_tools"`
+ HealthCheck bool `json:"health_check"`
}
// ShutdownRequest is the cooperative drain request sent before signal escalation.
@@ -155,6 +158,9 @@ func (r InitializeRequest) Validate() error {
if strings.TrimSpace(r.ProtocolVersion) == "" {
return errors.New("subprocess: initialize protocol_version is required")
}
+ if strings.TrimSpace(r.SessionNonce) == "" {
+ return errors.New("subprocess: initialize session_nonce is required")
+ }
if len(r.SupportedProtocolVersion) == 0 {
return errors.New("subprocess: initialize supported_protocol_versions is required")
}
diff --git a/internal/subprocess/handshake_test.go b/internal/subprocess/handshake_test.go
index f0f78f22b..943f01b37 100644
--- a/internal/subprocess/handshake_test.go
+++ b/internal/subprocess/handshake_test.go
@@ -169,3 +169,187 @@ func TestCloneInitializeBridgeRuntimeDoesNotAliasManagedInstanceState(t *testing
t.Fatalf("len(cloned.ManagedInstances) = %d, want %d", got, want)
}
}
+
+func TestInitializeBridgeRuntimeManagedInstanceHelpers(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 12, 10, 0, 0, time.UTC)
+ singleRuntime := InitializeBridgeRuntime{
+ RuntimeVersion: InitializeBridgeRuntimeVersion1,
+ Provider: "telegram-reference",
+ Platform: "telegram",
+ ManagedInstances: []InitializeBridgeManagedInstance{{
+ Instance: bridges.BridgeInstance{
+ ID: " brg-1 ",
+ Scope: bridges.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-reference",
+ DisplayName: "Telegram",
+ Enabled: true,
+ Status: bridges.BridgeStatusReady,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }},
+ }
+
+ managed, err := singleRuntime.SingleManagedInstance()
+ if err != nil {
+ t.Fatalf("SingleManagedInstance() error = %v", err)
+ }
+ if got, want := managed.Instance.ID, " brg-1 "; got != want {
+ t.Fatalf("SingleManagedInstance().Instance.ID = %q, want %q", got, want)
+ }
+
+ managed.Instance.ID = "mutated"
+ reloaded, ok := singleRuntime.ManagedInstance("brg-1")
+ if !ok {
+ t.Fatal("ManagedInstance(brg-1) = missing, want managed instance")
+ }
+ if got, want := reloaded.Instance.ID, " brg-1 "; got != want {
+ t.Fatalf("ManagedInstance(brg-1).Instance.ID = %q, want %q", got, want)
+ }
+ if _, ok := singleRuntime.ManagedInstance(" "); ok {
+ t.Fatal("ManagedInstance(blank) = found, want false")
+ }
+ if _, ok := singleRuntime.ManagedInstance("missing"); ok {
+ t.Fatal("ManagedInstance(missing) = found, want false")
+ }
+ if got, want := singleRuntime.ManagedBridgeInstanceIDs(), []string{
+ "brg-1",
+ }; len(got) != len(want) ||
+ got[0] != want[0] {
+ t.Fatalf("ManagedBridgeInstanceIDs() = %#v, want %#v", got, want)
+ }
+
+ if _, err := (InitializeBridgeRuntime{}).SingleManagedInstance(); err == nil ||
+ !strings.Contains(err.Error(), "is required") {
+ t.Fatalf("SingleManagedInstance() empty error = %v, want required error", err)
+ }
+
+ multiRuntime := singleRuntime
+ multiRuntime.ManagedInstances = append(multiRuntime.ManagedInstances, InitializeBridgeManagedInstance{
+ Instance: bridges.BridgeInstance{
+ ID: " brg-2 ",
+ Scope: bridges.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-reference",
+ DisplayName: "Telegram 2",
+ Enabled: true,
+ Status: bridges.BridgeStatusReady,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ })
+ if _, err := multiRuntime.SingleManagedInstance(); err == nil ||
+ !strings.Contains(err.Error(), "explicit managed instance selection") {
+ t.Fatalf("SingleManagedInstance() multi error = %v, want explicit selection error", err)
+ }
+ if got, want := multiRuntime.ManagedBridgeInstanceIDs(), []string{
+ "brg-1",
+ "brg-2",
+ }; len(got) != len(want) || got[0] != want[0] ||
+ got[1] != want[1] {
+ t.Fatalf("ManagedBridgeInstanceIDs() multi = %#v, want %#v", got, want)
+ }
+}
+
+func TestInitializeBridgeManagedInstanceValidateRejectsDuplicateSecretBindings(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 12, 15, 0, 0, time.UTC)
+ managed := InitializeBridgeManagedInstance{
+ Instance: bridges.BridgeInstance{
+ ID: "brg-dup",
+ Scope: bridges.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-reference",
+ DisplayName: "Telegram",
+ Enabled: true,
+ Status: bridges.BridgeStatusReady,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ BoundSecrets: []InitializeBridgeBoundSecret{
+ {BindingName: "bot_token", Kind: "token", Value: "secret-1"},
+ {BindingName: " bot_token ", Kind: "token", Value: "secret-2"},
+ },
+ }
+
+ err := managed.Validate()
+ if err == nil || !strings.Contains(err.Error(), "duplicated") {
+ t.Fatalf("managed.Validate() error = %v, want duplicated secret error", err)
+ }
+}
+
+func TestInitializeBridgeRuntimeValidateRejectsDuplicateManagedInstances(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 4, 15, 12, 20, 0, 0, time.UTC)
+ managed := InitializeBridgeManagedInstance{
+ Instance: bridges.BridgeInstance{
+ ID: "brg-dup",
+ Scope: bridges.ScopeGlobal,
+ Platform: "telegram",
+ ExtensionName: "telegram-reference",
+ DisplayName: "Telegram",
+ Enabled: true,
+ Status: bridges.BridgeStatusReady,
+ RoutingPolicy: bridges.RoutingPolicy{IncludePeer: true},
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }
+
+ runtime := InitializeBridgeRuntime{
+ RuntimeVersion: InitializeBridgeRuntimeVersion1,
+ Provider: "telegram-reference",
+ Platform: "telegram",
+ ManagedInstances: []InitializeBridgeManagedInstance{managed, managed},
+ }
+
+ err := runtime.Validate()
+ if err == nil || !strings.Contains(err.Error(), "duplicated") {
+ t.Fatalf("runtime.Validate() error = %v, want duplicated managed instance error", err)
+ }
+}
+
+func TestInitializeBridgeBoundSecretValidateRejectsMissingFields(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ secret InitializeBridgeBoundSecret
+ want string
+ }{
+ {
+ name: "missing binding name",
+ secret: InitializeBridgeBoundSecret{Kind: "token", Value: "secret"},
+ want: "binding_name",
+ },
+ {
+ name: "missing kind",
+ secret: InitializeBridgeBoundSecret{BindingName: "bot_token", Value: "secret"},
+ want: "kind",
+ },
+ {
+ name: "missing value",
+ secret: InitializeBridgeBoundSecret{BindingName: "bot_token", Kind: "token"},
+ want: "value",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ err := tc.secret.Validate()
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("secret.Validate() error = %v, want substring %q", err, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/subprocess/process_test.go b/internal/subprocess/process_test.go
index b6da83ed9..291ce4cdf 100644
--- a/internal/subprocess/process_test.go
+++ b/internal/subprocess/process_test.go
@@ -450,6 +450,13 @@ func TestInitializeRequestValidateRejectsMissingFields(t *testing.T) {
},
wantSub: "protocol_version",
},
+ {
+ name: "missing-session-nonce",
+ mutate: func(request *InitializeRequest) {
+ request.SessionNonce = ""
+ },
+ wantSub: "session_nonce",
+ },
{
name: "missing-supported-versions",
mutate: func(request *InitializeRequest) {
@@ -722,6 +729,7 @@ func newInitializeRequest(runtimeCfg InitializeRuntime) InitializeRequest {
ProtocolVersion: defaultProtocolVersion,
SupportedProtocolVersion: []string{defaultProtocolVersion},
AGHVersion: "dev",
+ SessionNonce: "session-nonce-test",
Extension: InitializeExtension{
Name: "test-extension",
Version: "0.1.0",
diff --git a/internal/tools/resource.go b/internal/tools/resource.go
new file mode 100644
index 000000000..e57b6f573
--- /dev/null
+++ b/internal/tools/resource.go
@@ -0,0 +1,61 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/pedronauck/agh/internal/resources"
+)
+
+const (
+ // ToolResourceKind is the canonical desired-state resource kind for tool records.
+ ToolResourceKind resources.ResourceKind = "tool"
+ toolResourceMaxBytes = 256 << 10
+)
+
+// NewResourceCodec builds the canonical tool resource codec.
+func NewResourceCodec() (resources.KindCodec[Tool], error) {
+ return resources.NewJSONCodec(ToolResourceKind, toolResourceMaxBytes, validateToolSpec)
+}
+
+func validateToolSpec(_ context.Context, scope resources.ResourceScope, spec Tool) (Tool, error) {
+ normalizedScope := scope.Normalize()
+ if err := normalizedScope.Validate("scope"); err != nil {
+ return Tool{}, err
+ }
+
+ normalized := Tool{
+ Name: strings.TrimSpace(spec.Name),
+ Description: strings.TrimSpace(spec.Description),
+ ReadOnly: spec.ReadOnly,
+ Source: spec.Source,
+ }
+ if normalized.Name == "" {
+ return Tool{}, fmt.Errorf("%w: tool.name is required", resources.ErrValidation)
+ }
+ if err := normalized.Source.Validate(); err != nil {
+ return Tool{}, err
+ }
+
+ if len(spec.InputSchema) > 0 {
+ var decoded any
+ if err := json.Unmarshal(spec.InputSchema, &decoded); err != nil {
+ return Tool{}, fmt.Errorf("%w: tool.input_schema: %v", resources.ErrValidation, err)
+ }
+ if decoded == nil {
+ return normalized, nil
+ }
+ if _, ok := decoded.(map[string]any); !ok {
+ return Tool{}, fmt.Errorf("%w: tool.input_schema must be a JSON object", resources.ErrValidation)
+ }
+ canonical, err := json.Marshal(decoded)
+ if err != nil {
+ return Tool{}, fmt.Errorf("tools: canonicalize input schema: %w", err)
+ }
+ normalized.InputSchema = append(json.RawMessage(nil), canonical...)
+ }
+
+ return normalized, nil
+}
diff --git a/internal/tools/resource_test.go b/internal/tools/resource_test.go
new file mode 100644
index 000000000..bd61bb9c5
--- /dev/null
+++ b/internal/tools/resource_test.go
@@ -0,0 +1,64 @@
+package tools
+
+import (
+ "testing"
+
+ "github.com/pedronauck/agh/internal/resources"
+ "github.com/pedronauck/agh/internal/testutil"
+)
+
+func TestToolResourceCodecCanonicalizesInputSchema(t *testing.T) {
+ t.Parallel()
+
+ codec, err := NewResourceCodec()
+ if err != nil {
+ t.Fatalf("NewResourceCodec() error = %v", err)
+ }
+
+ scope := resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal}
+ spec, err := codec.DecodeAndValidate(testutil.Context(t), scope, []byte(`{
+ "name": " lookup ",
+ "description": " search files ",
+ "input_schema": {
+ "properties": {"path": {"type": "string"}},
+ "type": "object"
+ },
+ "read_only": true,
+ "source": "extension"
+ }`))
+ if err != nil {
+ t.Fatalf("codec.DecodeAndValidate() error = %v", err)
+ }
+
+ if got, want := spec.Name, "lookup"; got != want {
+ t.Fatalf("spec.Name = %q, want %q", got, want)
+ }
+ if got, want := spec.Description, "search files"; got != want {
+ t.Fatalf("spec.Description = %q, want %q", got, want)
+ }
+ if got, want := string(spec.InputSchema), `{"properties":{"path":{"type":"string"}},"type":"object"}`; got != want {
+ t.Fatalf("spec.InputSchema = %s, want %s", got, want)
+ }
+}
+
+func TestToolResourceCodecRejectsInvalidSchema(t *testing.T) {
+ t.Parallel()
+
+ codec, err := NewResourceCodec()
+ if err != nil {
+ t.Fatalf("NewResourceCodec() error = %v", err)
+ }
+
+ _, err = codec.DecodeAndValidate(
+ testutil.Context(t),
+ resources.ResourceScope{Kind: resources.ResourceScopeKindGlobal},
+ []byte(`{
+ "name": "lookup",
+ "input_schema": "{not-json",
+ "source": "extension"
+ }`),
+ )
+ if err == nil {
+ t.Fatal("codec.DecodeAndValidate() error = nil, want invalid input_schema failure")
+ }
+}
diff --git a/internal/workspace/clone.go b/internal/workspace/clone.go
index 66f466906..4b4411736 100644
--- a/internal/workspace/clone.go
+++ b/internal/workspace/clone.go
@@ -4,6 +4,7 @@ import (
"maps"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/filesnap"
hookspkg "github.com/pedronauck/agh/internal/hooks"
)
@@ -14,11 +15,12 @@ func cloneSnapshots(snapshots map[string]filesnap.Snapshot) map[string]filesnap.
func cloneResolvedWorkspace(src *ResolvedWorkspace) ResolvedWorkspace {
return ResolvedWorkspace{
- Workspace: cloneWorkspace(src.Workspace),
- Config: cloneConfig(&src.Config),
- Agents: cloneAgentDefs(src.Agents),
- Skills: cloneSkillPaths(src.Skills),
- ResolvedAt: src.ResolvedAt,
+ Workspace: cloneWorkspace(src.Workspace),
+ Config: cloneConfig(&src.Config),
+ Agents: cloneAgentDefs(src.Agents),
+ Skills: cloneSkillPaths(src.Skills),
+ Environment: cloneEnvironmentResolved(src.Environment),
+ ResolvedAt: src.ResolvedAt,
}
}
@@ -29,6 +31,7 @@ func cloneWorkspace(src Workspace) Workspace {
AdditionalDirs: append([]string(nil), src.AdditionalDirs...),
Name: src.Name,
DefaultAgent: src.DefaultAgent,
+ EnvironmentRef: src.EnvironmentRef,
CreatedAt: src.CreatedAt,
UpdatedAt: src.UpdatedAt,
}
@@ -56,6 +59,7 @@ func cloneConfig(src *aghconfig.Config) aghconfig.Config {
Permissions: src.Permissions,
MCPServers: cloneMCPServers(src.MCPServers),
Providers: cloneProviders(src.Providers),
+ Environments: cloneEnvironmentProfiles(src.Environments),
Observability: src.Observability,
Log: src.Log,
Memory: src.Memory,
@@ -67,12 +71,56 @@ func cloneConfig(src *aghconfig.Config) aghconfig.Config {
AllowedMarketplaceHooks: append([]string(nil), src.Skills.AllowedMarketplaceHooks...),
Marketplace: src.Skills.Marketplace,
},
+ Extensions: src.Extensions,
+ Automation: src.Automation,
Hooks: aghconfig.HooksConfig{
Declarations: cloneHookDecls(src.Hooks.Declarations),
},
+ Network: src.Network,
}
}
+func cloneEnvironmentProfiles(src map[string]aghconfig.EnvironmentProfile) map[string]aghconfig.EnvironmentProfile {
+ if len(src) == 0 {
+ return map[string]aghconfig.EnvironmentProfile{}
+ }
+
+ cloned := make(map[string]aghconfig.EnvironmentProfile, len(src))
+ for name, profile := range src {
+ cloned[name] = cloneEnvironmentProfile(profile)
+ }
+ return cloned
+}
+
+func cloneEnvironmentProfile(src aghconfig.EnvironmentProfile) aghconfig.EnvironmentProfile {
+ return aghconfig.EnvironmentProfile{
+ Backend: src.Backend,
+ SyncMode: src.SyncMode,
+ Persistence: src.Persistence,
+ RuntimeRoot: src.RuntimeRoot,
+ Env: cloneStringMap(src.Env),
+ Network: aghconfig.NetworkProfile{
+ AllowPublicIngress: src.Network.AllowPublicIngress,
+ AllowOutbound: src.Network.AllowOutbound,
+ AllowList: append([]string(nil), src.Network.AllowList...),
+ DenyList: append([]string(nil), src.Network.DenyList...),
+ },
+ Daytona: src.Daytona,
+ }
+}
+
+func cloneEnvironmentResolved(src environment.Resolved) environment.Resolved {
+ cloned := src
+ cloned.Env = cloneStringMap(src.Env)
+ cloned.Network.AllowList = append([]string(nil), src.Network.AllowList...)
+ cloned.Network.DenyList = append([]string(nil), src.Network.DenyList...)
+ if src.Daytona != nil {
+ daytona := *src.Daytona
+ cloned.Daytona = &daytona
+ }
+ return cloned
+}
+
func cloneProviders(src map[string]aghconfig.ProviderConfig) map[string]aghconfig.ProviderConfig {
if len(src) == 0 {
return map[string]aghconfig.ProviderConfig{}
diff --git a/internal/workspace/resolver.go b/internal/workspace/resolver.go
index 60ec23b2c..7a3016d2b 100644
--- a/internal/workspace/resolver.go
+++ b/internal/workspace/resolver.go
@@ -11,6 +11,7 @@ import (
"time"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/filesnap"
)
@@ -20,6 +21,7 @@ type RegisterOptions struct {
Name string
AdditionalDirs []string
DefaultAgent string
+ EnvironmentRef string
}
// UpdateOptions describes mutable workspace registration fields.
@@ -27,6 +29,7 @@ type UpdateOptions struct {
Name *string
AdditionalDirs *[]string
DefaultAgent *string
+ EnvironmentRef *string
}
// Resolver resolves persisted workspaces into runtime workspace snapshots.
@@ -236,6 +239,10 @@ func (r *Resolver) buildResolvedWorkspace(
return ResolvedWorkspace{}, fmt.Errorf("workspace: load config for %q: %w", ws.RootDir, err)
}
applyDefaultAgentOverride(&cfg, ws.DefaultAgent)
+ resolvedEnvironment, err := resolveWorkspaceEnvironment(ws, &cfg)
+ if err != nil {
+ return ResolvedWorkspace{}, fmt.Errorf("workspace: resolve environment for %q: %w", ws.ID, err)
+ }
agents, err := loadAgents(ctx, scan.agents)
if err != nil {
@@ -245,14 +252,23 @@ func (r *Resolver) buildResolvedWorkspace(
skills := mergeSkillPaths(scan.skills)
return ResolvedWorkspace{
- Workspace: cloneWorkspace(ws),
- Config: cloneConfig(&cfg),
- Agents: cloneAgentDefs(agents),
- Skills: cloneSkillPaths(skills),
- ResolvedAt: r.now(),
+ Workspace: cloneWorkspace(ws),
+ Config: cloneConfig(&cfg),
+ Agents: cloneAgentDefs(agents),
+ Skills: cloneSkillPaths(skills),
+ Environment: cloneEnvironmentResolved(resolvedEnvironment),
+ ResolvedAt: r.now(),
}, nil
}
+func resolveWorkspaceEnvironment(ws Workspace, cfg *aghconfig.Config) (environment.Resolved, error) {
+ ref := strings.TrimSpace(ws.EnvironmentRef)
+ if ref == "" {
+ ref = strings.TrimSpace(cfg.Defaults.Environment)
+ }
+ return cfg.ResolveEnvironment(ref)
+}
+
func (c *cachedEntry) canReuse(ws Workspace, snapshots map[string]filesnap.Snapshot) bool {
if c == nil {
return false
@@ -263,6 +279,9 @@ func (c *cachedEntry) canReuse(ws Workspace, snapshots map[string]filesnap.Snaps
if strings.TrimSpace(c.workspace.DefaultAgent) != strings.TrimSpace(ws.DefaultAgent) {
return false
}
+ if strings.TrimSpace(c.workspace.EnvironmentRef) != strings.TrimSpace(ws.EnvironmentRef) {
+ return false
+ }
if strings.TrimSpace(c.workspace.RootDir) != strings.TrimSpace(ws.RootDir) {
return false
}
diff --git a/internal/workspace/resolver_crud.go b/internal/workspace/resolver_crud.go
index 11e88bf8b..759495035 100644
--- a/internal/workspace/resolver_crud.go
+++ b/internal/workspace/resolver_crud.go
@@ -95,6 +95,9 @@ func (r *Resolver) Update(ctx context.Context, id string, opts UpdateOptions) er
if opts.DefaultAgent != nil {
ws.DefaultAgent = strings.TrimSpace(*opts.DefaultAgent)
}
+ if opts.EnvironmentRef != nil {
+ ws.EnvironmentRef = strings.TrimSpace(*opts.EnvironmentRef)
+ }
ws.UpdatedAt = r.now()
if err := r.store.UpdateWorkspace(ctx, ws); err != nil {
@@ -159,6 +162,7 @@ func (r *Resolver) createWorkspaceRegistration(ctx context.Context, opts Registe
AdditionalDirs: additionalDirs,
Name: name,
DefaultAgent: strings.TrimSpace(opts.DefaultAgent),
+ EnvironmentRef: strings.TrimSpace(opts.EnvironmentRef),
CreatedAt: now,
UpdatedAt: now,
}
diff --git a/internal/workspace/resolver_integration_test.go b/internal/workspace/resolver_integration_test.go
index 6049814aa..add3fabdd 100644
--- a/internal/workspace/resolver_integration_test.go
+++ b/internal/workspace/resolver_integration_test.go
@@ -14,6 +14,7 @@ import (
"time"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/store/globaldb"
aghworkspace "github.com/pedronauck/agh/internal/workspace"
)
@@ -141,6 +142,70 @@ func TestResolverIntegrationResolveUpdatesStaleSymlinkRegistration(t *testing.T)
}
}
+func TestResolverIntegrationEnvironmentConfigRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ homePaths := newIntegrationHomePaths(t)
+ t.Setenv("AGH_HOME", homePaths.HomeDir)
+
+ db := openTestGlobalDB(t, ctx)
+ defer closeTestGlobalDB(t, ctx, db)
+
+ root := t.TempDir()
+ writeFile(t, homePaths.ConfigFile, `
+[defaults]
+environment = "daytona-dev"
+
+[environments.daytona-dev]
+backend = "daytona"
+sync_mode = "session-bidirectional"
+persistence = "reuse"
+runtime_root = "/home/daytona/workspace"
+
+[environments.daytona-dev.env]
+NODE_ENV = "development"
+
+[environments.daytona-dev.daytona]
+image = "ubuntu:24.04"
+snapshot = "snap-integration"
+`)
+
+ resolver := newIntegrationResolver(t, db, homePaths)
+ registered, err := resolver.Register(ctx, aghworkspace.RegisterOptions{
+ RootDir: root,
+ Name: "repo-env",
+ EnvironmentRef: "daytona-dev",
+ })
+ if err != nil {
+ t.Fatalf("Register() error = %v", err)
+ }
+
+ stored, err := db.GetWorkspace(ctx, registered.ID)
+ if err != nil {
+ t.Fatalf("GetWorkspace() error = %v", err)
+ }
+ if got, want := stored.EnvironmentRef, "daytona-dev"; got != want {
+ t.Fatalf("stored EnvironmentRef = %q, want %q", got, want)
+ }
+
+ resolved, err := resolver.Resolve(ctx, registered.ID)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if resolved.Environment.Profile != "daytona-dev" ||
+ resolved.Environment.Backend != environment.BackendDaytona ||
+ resolved.Environment.Persistence != environment.PersistenceReuse {
+ t.Fatalf("resolved Environment = %#v, want Daytona profile", resolved.Environment)
+ }
+ if resolved.Environment.Daytona == nil ||
+ resolved.Environment.Daytona.StartupSource != environment.DaytonaStartupSourceSnapshot ||
+ resolved.Environment.Daytona.StartupRef != "snap-integration" {
+ t.Fatalf("resolved Daytona config = %#v, want snapshot startup", resolved.Environment.Daytona)
+ }
+ if got, want := resolved.Environment.Env["NODE_ENV"], "development"; got != want {
+ t.Fatalf("resolved Env[NODE_ENV] = %q, want %q", got, want)
+ }
+}
+
func newIntegrationResolver(t *testing.T, store aghworkspace.Store, homePaths aghconfig.HomePaths) *aghworkspace.Resolver {
t.Helper()
diff --git a/internal/workspace/resolver_test.go b/internal/workspace/resolver_test.go
index a62762b05..d56803ae1 100644
--- a/internal/workspace/resolver_test.go
+++ b/internal/workspace/resolver_test.go
@@ -14,7 +14,9 @@ import (
"time"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/filesnap"
+ hookspkg "github.com/pedronauck/agh/internal/hooks"
)
func TestResolveRoutesByIdentifierType(t *testing.T) {
@@ -110,6 +112,161 @@ func TestResolveRoutesByIdentifierType(t *testing.T) {
}
}
+func TestResolveWorkspaceEnvironmentCascade(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ homePaths := newTestHomePaths(t)
+ baseConfig := validConfig(homePaths)
+ baseConfig.Defaults.Environment = "default-env"
+ baseConfig.Environments["default-env"] = aghconfig.EnvironmentProfile{
+ Backend: "daytona",
+ Persistence: "reuse",
+ Daytona: aghconfig.DaytonaProfile{
+ Snapshot: "snap-default",
+ },
+ }
+ baseConfig.Environments["explicit-env"] = aghconfig.EnvironmentProfile{
+ Backend: "daytona",
+ SyncMode: "none",
+ Daytona: aghconfig.DaytonaProfile{
+ Snapshot: "snap-explicit",
+ },
+ }
+
+ tests := []struct {
+ name string
+ workspace Workspace
+ cfg aghconfig.Config
+ wantProfile string
+ wantBackend environment.Backend
+ wantSync environment.SyncMode
+ }{
+ {
+ name: "workspace ref wins over default",
+ workspace: Workspace{
+ ID: "ws_explicit",
+ RootDir: mustCanonicalRoot(t, t.TempDir()),
+ Name: "explicit",
+ EnvironmentRef: "explicit-env",
+ },
+ cfg: baseConfig,
+ wantProfile: "explicit-env",
+ wantBackend: environment.BackendDaytona,
+ wantSync: environment.SyncModeNone,
+ },
+ {
+ name: "defaults environment applies when workspace omits ref",
+ workspace: Workspace{
+ ID: "ws_default",
+ RootDir: mustCanonicalRoot(t, t.TempDir()),
+ Name: "default",
+ },
+ cfg: baseConfig,
+ wantProfile: "default-env",
+ wantBackend: environment.BackendDaytona,
+ wantSync: environment.SyncModeSessionBidirectional,
+ },
+ {
+ name: "implicit local applies with no workspace or default ref",
+ workspace: Workspace{
+ ID: "ws_local",
+ RootDir: mustCanonicalRoot(t, t.TempDir()),
+ Name: "local",
+ },
+ cfg: func() aghconfig.Config {
+ cfg := validConfig(homePaths)
+ cfg.Defaults.Environment = ""
+ return cfg
+ }(),
+ wantProfile: "local",
+ wantBackend: environment.BackendLocal,
+ wantSync: environment.SyncModeNone,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ store := newMockWorkspaceStore(tt.workspace)
+ loader := &countingConfigLoader{cfg: tt.cfg}
+ resolver := newTestResolver(t, store,
+ WithHomePaths(homePaths),
+ WithConfigLoader(loader.Load),
+ )
+
+ resolved, err := resolver.Resolve(ctx, tt.workspace.ID)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if resolved.Environment.Profile != tt.wantProfile ||
+ resolved.Environment.Backend != tt.wantBackend ||
+ resolved.Environment.SyncMode != tt.wantSync {
+ t.Fatalf("resolved environment = %#v, want profile=%q backend=%q sync=%q",
+ resolved.Environment,
+ tt.wantProfile,
+ tt.wantBackend,
+ tt.wantSync,
+ )
+ }
+ })
+ }
+}
+
+func TestRegisterUpdateAndLoadWorkspaceEnvironmentRef(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ homePaths := newTestHomePaths(t)
+ cfg := validConfig(homePaths)
+ cfg.Environments["daytona-dev"] = aghconfig.EnvironmentProfile{
+ Backend: "daytona",
+ Daytona: aghconfig.DaytonaProfile{Snapshot: "snap-dev"},
+ }
+ cfg.Environments["local-dev"] = aghconfig.EnvironmentProfile{Backend: "local"}
+
+ store := newMockWorkspaceStore()
+ resolver := newTestResolver(t, store,
+ WithHomePaths(homePaths),
+ WithConfigLoader((&countingConfigLoader{cfg: cfg}).Load),
+ WithIDGenerator(func(string) string { return "ws_env" }),
+ )
+
+ root := t.TempDir()
+ registered, err := resolver.Register(ctx, RegisterOptions{
+ RootDir: root,
+ Name: "env-workspace",
+ EnvironmentRef: "daytona-dev",
+ })
+ if err != nil {
+ t.Fatalf("Register() error = %v", err)
+ }
+ if got, want := registered.EnvironmentRef, "daytona-dev"; got != want {
+ t.Fatalf("registered EnvironmentRef = %q, want %q", got, want)
+ }
+
+ loaded, err := resolver.Get(ctx, registered.ID)
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+ if got, want := loaded.EnvironmentRef, "daytona-dev"; got != want {
+ t.Fatalf("loaded EnvironmentRef = %q, want %q", got, want)
+ }
+
+ nextEnvironment := "local-dev"
+ if err := resolver.Update(ctx, registered.ID, UpdateOptions{EnvironmentRef: &nextEnvironment}); err != nil {
+ t.Fatalf("Update() error = %v", err)
+ }
+ updated, err := resolver.Get(ctx, registered.ID)
+ if err != nil {
+ t.Fatalf("Get(updated) error = %v", err)
+ }
+ if got, want := updated.EnvironmentRef, "local-dev"; got != want {
+ t.Fatalf("updated EnvironmentRef = %q, want %q", got, want)
+ }
+}
+
func TestResolveOrRegisterExistingWorkspace(t *testing.T) {
t.Parallel()
@@ -660,6 +817,7 @@ func TestListReturnsClonedWorkspaces(t *testing.T) {
func TestCloneConfigProducesDeepCopy(t *testing.T) {
t.Parallel()
+ toolReadOnly := true
original := aghconfig.Config{
Session: aghconfig.SessionConfig{
Limits: aghconfig.SessionLimitsConfig{
@@ -686,12 +844,29 @@ func TestCloneConfigProducesDeepCopy(t *testing.T) {
DisabledSkills: []string{"alpha"},
PollInterval: time.Second,
},
+ Hooks: aghconfig.HooksConfig{
+ Declarations: []hookspkg.HookDecl{{
+ Name: "test-hook",
+ Args: []string{"one"},
+ Env: map[string]string{"TOKEN": "one"},
+ Metadata: map[string]string{
+ "origin": "test",
+ },
+ Matcher: hookspkg.HookMatcher{
+ ToolReadOnly: &toolReadOnly,
+ },
+ }},
+ },
}
cloned := cloneConfig(&original)
cloned.Session.Limits.Timeout = 2 * time.Minute
cloned.Providers["claude"] = aghconfig.ProviderConfig{}
cloned.Skills.DisabledSkills[0] = "beta"
+ cloned.Hooks.Declarations[0].Args[0] = "two"
+ cloned.Hooks.Declarations[0].Env["TOKEN"] = "two"
+ cloned.Hooks.Declarations[0].Metadata["origin"] = "mutated"
+ *cloned.Hooks.Declarations[0].Matcher.ToolReadOnly = false
if got, want := original.Session.Limits.Timeout, time.Minute; got != want {
t.Fatalf("original Session.Limits.Timeout = %s, want %s", got, want)
@@ -703,6 +878,12 @@ func TestCloneConfigProducesDeepCopy(t *testing.T) {
if got, want := original.Skills.DisabledSkills, []string{"alpha"}; !slices.Equal(got, want) {
t.Fatalf("original Skills.DisabledSkills = %#v, want %#v", got, want)
}
+ hook := original.Hooks.Declarations[0]
+ if hook.Args[0] != "one" || hook.Env["TOKEN"] != "one" ||
+ hook.Metadata["origin"] != "test" || hook.Matcher.ToolReadOnly == nil ||
+ !*hook.Matcher.ToolReadOnly {
+ t.Fatalf("original hook mutated: %#v", hook)
+ }
}
func TestWorkspaceHelperFunctions(t *testing.T) {
diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go
index 17f3081aa..fab10ef49 100644
--- a/internal/workspace/workspace.go
+++ b/internal/workspace/workspace.go
@@ -8,6 +8,7 @@ import (
"time"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
)
var (
@@ -32,6 +33,7 @@ type Workspace struct {
AdditionalDirs []string
Name string
DefaultAgent string
+ EnvironmentRef string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -39,10 +41,11 @@ type Workspace struct {
// ResolvedWorkspace is the computed workspace snapshot returned by a resolver.
type ResolvedWorkspace struct {
Workspace
- Config aghconfig.Config
- Agents []aghconfig.AgentDef
- Skills []SkillPath
- ResolvedAt time.Time
+ Config aghconfig.Config
+ Agents []aghconfig.AgentDef
+ Skills []SkillPath
+ Environment environment.Resolved
+ ResolvedAt time.Time
}
// SkillPath identifies a discovered skill directory and its origin.
diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go
index 1486f0814..6c2ee807f 100644
--- a/internal/workspace/workspace_test.go
+++ b/internal/workspace/workspace_test.go
@@ -8,6 +8,7 @@ import (
"time"
aghconfig "github.com/pedronauck/agh/internal/config"
+ "github.com/pedronauck/agh/internal/environment"
"github.com/pedronauck/agh/internal/workspace"
)
@@ -192,6 +193,7 @@ func TestWorkspaceStructSurface(t *testing.T) {
{name: "AdditionalDirs", fieldType: reflect.TypeFor[[]string]()},
{name: "Name", fieldType: reflect.TypeFor[string]()},
{name: "DefaultAgent", fieldType: reflect.TypeFor[string]()},
+ {name: "EnvironmentRef", fieldType: reflect.TypeFor[string]()},
{name: "CreatedAt", fieldType: reflect.TypeFor[time.Time]()},
{name: "UpdatedAt", fieldType: reflect.TypeFor[time.Time]()},
},
@@ -204,6 +206,7 @@ func TestWorkspaceStructSurface(t *testing.T) {
{name: "Config", fieldType: reflect.TypeFor[aghconfig.Config]()},
{name: "Agents", fieldType: reflect.TypeFor[[]aghconfig.AgentDef]()},
{name: "Skills", fieldType: reflect.TypeFor[[]workspace.SkillPath]()},
+ {name: "Environment", fieldType: reflect.TypeFor[environment.Resolved]()},
{name: "ResolvedAt", fieldType: reflect.TypeFor[time.Time]()},
},
},
diff --git a/openapi/agh.json b/openapi/agh.json
index 83043fa4a..9d685d561 100644
--- a/openapi/agh.json
+++ b/openapi/agh.json
@@ -7733,6 +7733,11 @@
"session.post_resume",
"session.pre_stop",
"session.post_stop",
+ "environment.prepare",
+ "environment.ready",
+ "environment.sync.before",
+ "environment.sync.after",
+ "environment.stop",
"input.pre_submit",
"prompt.post_assemble",
"event.pre_record",
@@ -7818,6 +7823,15 @@
"decision_class": {
"type": "string"
},
+ "environment_backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "environment_profile": {
+ "type": "string"
+ },
"input_class": {
"type": "string"
},
@@ -7830,6 +7844,9 @@
"session_type": {
"type": "string"
},
+ "sync_direction": {
+ "type": "string"
+ },
"tool_name": {
"type": "string"
},
@@ -8105,6 +8122,11 @@
"session.post_resume",
"session.pre_stop",
"session.post_stop",
+ "environment.prepare",
+ "environment.ready",
+ "environment.sync.before",
+ "environment.sync.after",
+ "environment.stop",
"input.pre_submit",
"prompt.post_assemble",
"event.pre_record",
@@ -9107,6 +9129,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -9424,6 +9471,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -10863,80 +10935,1174 @@
"type": "integer"
}
},
- "required": [
- "auth_failures_total",
- "delivery_backlog",
- "delivery_dropped_total",
- "delivery_failures_total",
- "route_count",
- "status_counts",
- "total_instances"
- ],
+ "required": [
+ "auth_failures_total",
+ "delivery_backlog",
+ "delivery_dropped_total",
+ "delivery_failures_total",
+ "route_count",
+ "status_counts",
+ "total_instances"
+ ],
+ "type": "object"
+ },
+ "global_db_size_bytes": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "session_db_size_bytes": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "status": {
+ "type": "string"
+ },
+ "uptime_seconds": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "active_agents",
+ "active_sessions",
+ "bridges",
+ "global_db_size_bytes",
+ "session_db_size_bytes",
+ "status",
+ "uptime_seconds",
+ "version"
+ ],
+ "type": "object"
+ },
+ "memory": {
+ "properties": {
+ "dream_enabled": {
+ "type": "boolean"
+ },
+ "global_files": {
+ "type": "integer"
+ },
+ "last_consolidation": {
+ "format": "date-time",
+ "nullable": true,
+ "type": "string"
+ },
+ "workspace_files": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "dream_enabled",
+ "global_files",
+ "last_consolidation",
+ "workspace_files"
+ ],
+ "type": "object"
+ }
+ },
+ "required": ["automation", "health", "memory"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "default": {
+ "description": ""
+ }
+ },
+ "summary": "Get daemon health and memory health",
+ "tags": ["observe"],
+ "x-agh-transports": ["http", "uds"]
+ }
+ },
+ "/api/resources": {
+ "get": {
+ "operationId": "listResources",
+ "parameters": [
+ {
+ "description": "Filter by resource kind",
+ "in": "query",
+ "name": "kind",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by resource scope kind",
+ "in": "query",
+ "name": "scope_kind",
+ "schema": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by workspace scope id",
+ "in": "query",
+ "name": "scope_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped owner kind",
+ "in": "query",
+ "name": "owner_kind",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped owner id",
+ "in": "query",
+ "name": "owner_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped source kind",
+ "in": "query",
+ "name": "source_kind",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped source id",
+ "in": "query",
+ "name": "source_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Maximum number of records to return",
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "records": {
+ "items": {
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "owner": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "source": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "spec": {},
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "version": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "created_at",
+ "id",
+ "kind",
+ "owner",
+ "scope",
+ "source",
+ "spec",
+ "updated_at",
+ "version"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": ["records"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Invalid resource filter"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "default": {
+ "description": ""
+ }
+ },
+ "summary": "List desired-state resources on the local operator control plane",
+ "tags": ["resources"],
+ "x-agh-transports": ["http", "uds"]
+ }
+ },
+ "/api/resources/{kind}": {
+ "get": {
+ "operationId": "listResourcesByKind",
+ "parameters": [
+ {
+ "description": "Resource kind",
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by resource scope kind",
+ "in": "query",
+ "name": "scope_kind",
+ "schema": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by workspace scope id",
+ "in": "query",
+ "name": "scope_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped owner kind",
+ "in": "query",
+ "name": "owner_kind",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped owner id",
+ "in": "query",
+ "name": "owner_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped source kind",
+ "in": "query",
+ "name": "source_kind",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Filter by stamped source id",
+ "in": "query",
+ "name": "source_id",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Maximum number of records to return",
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "records": {
+ "items": {
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "owner": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "source": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "spec": {},
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "version": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "created_at",
+ "id",
+ "kind",
+ "owner",
+ "scope",
+ "source",
+ "spec",
+ "updated_at",
+ "version"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": ["records"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Invalid resource filter"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "default": {
+ "description": ""
+ }
+ },
+ "summary": "List one desired-state resource kind on the local operator control plane",
+ "tags": ["resources"],
+ "x-agh-transports": ["http", "uds"]
+ }
+ },
+ "/api/resources/{kind}/{id}": {
+ "delete": {
+ "operationId": "deleteResource",
+ "parameters": [
+ {
+ "description": "Resource kind",
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Resource id",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "expected_version": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": ["expected_version"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "JSON request body",
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "Deleted"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Invalid delete request"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Rate limited"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "default": {
+ "description": ""
+ }
+ },
+ "summary": "Delete one desired-state resource on the local operator control plane",
+ "tags": ["resources"],
+ "x-agh-transports": ["http", "uds"]
+ },
+ "get": {
+ "operationId": "getResource",
+ "parameters": [
+ {
+ "description": "Resource kind",
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Resource id",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "record": {
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "owner": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "source": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "spec": {},
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "version": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "created_at",
+ "id",
+ "kind",
+ "owner",
+ "scope",
+ "source",
+ "spec",
+ "updated_at",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "required": ["record"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Resource not found"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Invalid resource identifier"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Internal server error"
+ },
+ "default": {
+ "description": ""
+ }
+ },
+ "summary": "Read one desired-state resource on the local operator control plane",
+ "tags": ["resources"],
+ "x-agh-transports": ["http", "uds"]
+ },
+ "put": {
+ "operationId": "putResource",
+ "parameters": [
+ {
+ "description": "Resource kind",
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Resource id",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "expected_version": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "spec": {}
+ },
+ "required": ["scope", "spec"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "JSON request body",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "record": {
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "owner": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "source": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "spec": {},
+ "updated_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "version": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "created_at",
+ "id",
+ "kind",
+ "owner",
+ "scope",
+ "source",
+ "spec",
+ "updated_at",
+ "version"
+ ],
+ "type": "object"
+ }
+ },
+ "required": ["record"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Updated"
+ },
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "record": {
+ "properties": {
+ "created_at": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ },
+ "owner": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
+ },
+ "scope": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "enum": ["global", "workspace"],
+ "type": "string"
+ }
+ },
+ "required": ["kind"],
"type": "object"
},
- "global_db_size_bytes": {
- "format": "int64",
- "type": "integer"
- },
- "session_db_size_bytes": {
- "format": "int64",
- "type": "integer"
+ "source": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "kind"],
+ "type": "object"
},
- "status": {
+ "spec": {},
+ "updated_at": {
+ "format": "date-time",
"type": "string"
},
- "uptime_seconds": {
+ "version": {
"format": "int64",
"type": "integer"
- },
- "version": {
- "type": "string"
}
},
"required": [
- "active_agents",
- "active_sessions",
- "bridges",
- "global_db_size_bytes",
- "session_db_size_bytes",
- "status",
- "uptime_seconds",
+ "created_at",
+ "id",
+ "kind",
+ "owner",
+ "scope",
+ "source",
+ "spec",
+ "updated_at",
"version"
],
"type": "object"
- },
- "memory": {
- "properties": {
- "dream_enabled": {
- "type": "boolean"
- },
- "global_files": {
- "type": "integer"
- },
- "last_consolidation": {
- "format": "date-time",
- "nullable": true,
- "type": "string"
- },
- "workspace_files": {
- "type": "integer"
- }
- },
- "required": [
- "dream_enabled",
- "global_files",
- "last_consolidation",
- "workspace_files"
- ],
- "type": "object"
}
},
- "required": ["automation", "health", "memory"],
+ "required": ["record"],
"type": "object"
}
}
},
- "description": "OK"
+ "description": "Created"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "413": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Payload too large"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Invalid resource payload"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "required": ["error"],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Rate limited"
},
"500": {
"content": {
@@ -10958,8 +12124,8 @@
"description": ""
}
},
- "summary": "Get daemon health and memory health",
- "tags": ["observe"],
+ "summary": "Create or replace one desired-state resource on the local operator control plane",
+ "tags": ["resources"],
"x-agh-transports": ["http", "uds"]
}
},
@@ -11020,6 +12186,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -11196,6 +12387,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -11444,6 +12660,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -12094,6 +13335,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -18658,6 +19924,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"id": {
"type": "string"
},
@@ -18732,6 +20001,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"name": {
"type": "string"
},
@@ -18768,6 +20040,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"id": {
"type": "string"
},
@@ -18898,6 +20173,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"id": {
"type": "string"
},
@@ -19162,6 +20440,31 @@
"format": "date-time",
"type": "string"
},
+ "environment": {
+ "nullable": true,
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "environment_id": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "last_sync_error": {
+ "type": "string"
+ },
+ "profile": {
+ "type": "string"
+ },
+ "provider_state_json": {},
+ "state": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"id": {
"type": "string"
},
@@ -19250,6 +20553,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"id": {
"type": "string"
},
@@ -19351,12 +20657,21 @@
"nullable": true,
"type": "string"
},
+ "environment_ref": {
+ "nullable": true,
+ "type": "string"
+ },
"name": {
"nullable": true,
"type": "string"
}
},
- "required": ["add_dirs", "default_agent", "name"],
+ "required": [
+ "add_dirs",
+ "default_agent",
+ "environment_ref",
+ "name"
+ ],
"type": "object"
}
}
@@ -19385,6 +20700,9 @@
"default_agent": {
"type": "string"
},
+ "environment_ref": {
+ "type": "string"
+ },
"id": {
"type": "string"
},
@@ -19503,6 +20821,9 @@
{
"name": "observe"
},
+ {
+ "name": "resources"
+ },
{
"name": "sessions"
},
diff --git a/sdk/examples/telegram-reference/main_test.go b/sdk/examples/telegram-reference/main_test.go
index ed13242ff..23e31112f 100644
--- a/sdk/examples/telegram-reference/main_test.go
+++ b/sdk/examples/telegram-reference/main_test.go
@@ -803,6 +803,7 @@ func testInitializeRequest(
ProtocolVersion: "1",
SupportedProtocolVersion: []string{"1"},
AGHVersion: "0.5.0",
+ SessionNonce: "nonce-test",
Extension: subprocess.InitializeExtension{
Name: "telegram-reference",
Version: "0.1.0",
diff --git a/sdk/typescript/src/extension.test.ts b/sdk/typescript/src/extension.test.ts
index 5b02ab8c2..114771b27 100644
--- a/sdk/typescript/src/extension.test.ts
+++ b/sdk/typescript/src/extension.test.ts
@@ -17,6 +17,7 @@ function initializeFor(extension: Extension): InitializeRequest {
protocol_version: "1",
supported_protocol_versions: ["1"],
agh_version: "0.5.0",
+ session_nonce: "session-nonce-test",
extension: {
name: extension.definition.name,
version: extension.definition.version,
@@ -26,13 +27,15 @@ function initializeFor(extension: Extension): InitializeRequest {
provides: extension.definition.capabilities?.provides ?? [],
granted_actions: extension.definition.actions?.requires ?? [],
granted_security: extension.definition.security?.capabilities ?? [],
+ granted_resource_kinds: [],
+ granted_resource_scopes: [],
},
methods: {
daemon_requests: methods.filter(method =>
- ["execute_hook", "health_check", "shutdown", "provide_tools"].includes(method)
+ ["execute_hook", "health_check", "shutdown"].includes(method)
),
extension_services: methods.filter(
- method => !["execute_hook", "health_check", "shutdown", "provide_tools"].includes(method)
+ method => !["execute_hook", "health_check", "shutdown"].includes(method)
),
},
runtime: {
@@ -127,37 +130,24 @@ describe("Extension", () => {
provides: [],
granted_actions: [],
granted_security: [],
+ granted_resource_kinds: [],
+ granted_resource_scopes: [],
},
})
).rejects.toBeInstanceOf(CapabilityDeniedError);
});
- it("serves provide_tools and default health checks", async () => {
+ it("serves default health checks", async () => {
const harness = new TestHarness();
const extension = new Extension({
name: "tools",
version: "0.1.0",
});
- extension.handle("provide_tools", async () => ({
- tools: [
- {
- name: "lookup",
- description: "finds things",
- input_schema: { type: "object" },
- read_only: true,
- source: "extension",
- },
- ],
- }));
-
await harness.loadExtension(extension, {
- daemonRequests: ["health_check", "shutdown", "provide_tools"],
+ daemonRequests: ["health_check", "shutdown"],
});
await expect(harness.call("health_check", {})).resolves.toMatchObject({ healthy: true });
- await expect(harness.call("provide_tools", {})).resolves.toMatchObject({
- tools: [expect.objectContaining({ name: "lookup" })],
- });
});
it("negotiates bridges/deliver for bridge adapters and exposes scoped runtime data", async () => {
@@ -212,11 +202,45 @@ describe("Extension", () => {
});
expect(harness.getLastInitializeRequest()).toMatchObject({
+ session_nonce: "session-nonce-test",
+ capabilities: {
+ granted_resource_kinds: [],
+ granted_resource_scopes: [],
+ },
methods: { extension_services: ["bridges/deliver"] },
});
expect(ready).toHaveBeenCalledWith("chan-1");
});
+ it("captures session nonce and resource grants during initialize", async () => {
+ const ready = vi.fn();
+ const harness = new TestHarness();
+ const extension = new Extension({
+ name: "resource-ext",
+ version: "0.1.0",
+ });
+
+ extension.onReady((_host, session) => {
+ ready({
+ session_nonce: session.initializeRequest.session_nonce,
+ granted_resource_kinds: session.initializeRequest.capabilities.granted_resource_kinds,
+ granted_resource_scopes: session.initializeRequest.capabilities.granted_resource_scopes,
+ });
+ });
+
+ await harness.loadExtension(extension, {
+ sessionNonce: "nonce-resource",
+ grantedResourceKinds: ["tool", "skill"],
+ grantedResourceScopes: ["workspace"],
+ });
+
+ expect(ready).toHaveBeenCalledWith({
+ session_nonce: "nonce-resource",
+ granted_resource_kinds: ["tool", "skill"],
+ granted_resource_scopes: ["workspace"],
+ });
+ });
+
it("rejects bridge.adapter initialize when bridges/deliver is not implemented", async () => {
const pair = createMockTransportPair();
const extension = new Extension(
diff --git a/sdk/typescript/src/extension.ts b/sdk/typescript/src/extension.ts
index e1458028b..685d20f61 100644
--- a/sdk/typescript/src/extension.ts
+++ b/sdk/typescript/src/extension.ts
@@ -20,7 +20,6 @@ import type {
InitializeResponse,
InitializeRuntime,
JSONRPCRequestEnvelope,
- ProvideToolsResult,
ShutdownRequest,
ShutdownResponse,
} from "./types.js";
@@ -155,9 +154,6 @@ export class Extension {
for (const method of this.handlers.keys()) {
methods.add(method);
}
- if (this.handlers.has("provide_tools")) {
- methods.add("provide_tools");
- }
return Array.from(methods).sort();
}
@@ -169,7 +165,6 @@ export class Extension {
this.bindMethod("initialize");
this.bindMethod("health_check");
this.bindMethod("shutdown");
- this.bindMethod("provide_tools");
for (const method of this.handlers.keys()) {
this.bindMethod(method);
}
@@ -212,8 +207,6 @@ export class Extension {
return await this.handleHealthCheck(request, params);
case "shutdown":
return await this.handleShutdown(request, params);
- case "provide_tools":
- return await this.handleProvideTools(request, params);
default:
return await this.handleUserMethod(method, request, params);
}
@@ -255,7 +248,6 @@ export class Extension {
supported_hook_events: this.getSupportedHookEvents(),
supports: {
health_check: true,
- provide_tools: this.handlers.has("provide_tools"),
},
};
@@ -331,24 +323,6 @@ export class Extension {
return { acknowledged: true };
}
- private async handleProvideTools(
- request: JSONRPCRequestEnvelope,
- params: unknown
- ): Promise {
- const customHandler = this.handlers.get("provide_tools");
- if (!customHandler) {
- throw new MethodNotFoundError("provide_tools");
- }
- const result = (await customHandler(
- this.makeContext(request),
- params as never
- )) as ProvideToolsResult;
- if (!Array.isArray(result?.tools)) {
- throw new InvalidParamsError("provide_tools must return a tools array");
- }
- return result;
- }
-
private async handleUserMethod(
method: string,
request: JSONRPCRequestEnvelope,
@@ -388,6 +362,9 @@ export class Extension {
if (!request.protocol_version) {
throw new InvalidParamsError("protocol_version is required");
}
+ if (typeof request.session_nonce !== "string" || request.session_nonce.trim() === "") {
+ throw new InvalidParamsError("session_nonce is required");
+ }
if (
!Array.isArray(request.supported_protocol_versions) ||
request.supported_protocol_versions.length === 0
@@ -397,6 +374,24 @@ export class Extension {
if (!request.extension?.name || !request.extension?.version) {
throw new InvalidParamsError("extension identity is required");
}
+ if (!request.capabilities || typeof request.capabilities !== "object") {
+ throw new InvalidParamsError("capabilities are required");
+ }
+ if (!Array.isArray(request.capabilities.provides)) {
+ throw new InvalidParamsError("capabilities.provides must be an array");
+ }
+ if (!Array.isArray(request.capabilities.granted_actions)) {
+ throw new InvalidParamsError("capabilities.granted_actions must be an array");
+ }
+ if (!Array.isArray(request.capabilities.granted_security)) {
+ throw new InvalidParamsError("capabilities.granted_security must be an array");
+ }
+ if (!Array.isArray(request.capabilities.granted_resource_kinds)) {
+ throw new InvalidParamsError("capabilities.granted_resource_kinds must be an array");
+ }
+ if (!Array.isArray(request.capabilities.granted_resource_scopes)) {
+ throw new InvalidParamsError("capabilities.granted_resource_scopes must be an array");
+ }
if (!request.runtime) {
throw new InvalidParamsError("runtime is required");
}
diff --git a/sdk/typescript/src/generated/contracts.ts b/sdk/typescript/src/generated/contracts.ts
index 0af117555..916d9548a 100644
--- a/sdk/typescript/src/generated/contracts.ts
+++ b/sdk/typescript/src/generated/contracts.ts
@@ -21,11 +21,17 @@ export type HostAPIMethod =
| "bridges/instances/list"
| "bridges/instances/report_state"
| "bridges/messages/ingest"
+ | "environment/exec"
+ | "environment/info"
+ | "environment/list"
| "memory/forget"
| "memory/recall"
| "memory/store"
| "observe/events"
| "observe/health"
+ | "resources/get"
+ | "resources/list"
+ | "resources/snapshot"
| "sessions/create"
| "sessions/events"
| "sessions/list"
@@ -64,6 +70,11 @@ export type HookEvent =
| "session.post_resume"
| "session.pre_stop"
| "session.post_stop"
+ | "environment.prepare"
+ | "environment.ready"
+ | "environment.sync.before"
+ | "environment.sync.after"
+ | "environment.stop"
| "input.pre_submit"
| "prompt.post_assemble"
| "event.pre_record"
@@ -685,6 +696,208 @@ export interface DeliveryRequest {
export type EmptyResult = Record;
+export interface EnvironmentExecParams {
+ session_id: string;
+ command: string;
+ timeout?: number;
+}
+
+export interface EnvironmentExecResult {
+ exit_code: number;
+ stdout?: string;
+ stderr?: string;
+}
+
+export interface EnvironmentInfoParams {
+ session_id: string;
+}
+
+export interface EnvironmentInfoResult {
+ environment_id: string;
+ backend: string;
+ profile: string;
+ instance_id: string;
+ runtime_root: string;
+ sync_state: string;
+ created_at: ISODateTime;
+ last_sync_error: string;
+}
+
+export interface EnvironmentListParams {
+ workspace?: string;
+}
+
+export interface EnvironmentSummary {
+ session_id: string;
+ environment_id: string;
+ backend: string;
+ profile?: string;
+ instance_id?: string;
+ state: string;
+ sync_state?: string;
+}
+
+export interface EnvironmentListResult {
+ environments: EnvironmentSummary[];
+}
+
+export type EnvironmentObservationPatch = Record;
+
+export interface EnvironmentPreparePatch {
+ deny?: boolean;
+ deny_reason?: string;
+ env_overrides?: Record;
+}
+
+export interface EnvironmentProfilePayload {
+ profile?: string;
+ backend?: string;
+ sync_mode?: string;
+ persistence?: string;
+ runtime_root?: string;
+ destroy_on_stop?: boolean;
+ env?: Record;
+}
+
+export interface EnvironmentPreparePayload {
+ event: HookEvent;
+ timestamp: ISODateTime;
+ session_id?: string;
+ session_name?: string;
+ session_type?: string;
+ agent_name?: string;
+ workspace_id?: string;
+ workspace?: string;
+ acp_session_id?: string;
+ state?: string;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+ environment_id?: string;
+ backend?: string;
+ profile: EnvironmentProfilePayload;
+ local_root?: string;
+ local_additional_dirs?: string[];
+ agent_command?: string;
+ agent_env?: string[];
+ permissions?: string;
+ resume_acp_state?: string;
+ env_overrides?: Record;
+ denied?: boolean;
+ deny_reason?: string;
+}
+
+export type EnvironmentReadyPatch = Record;
+
+export interface EnvironmentReadyPayload {
+ event: HookEvent;
+ timestamp: ISODateTime;
+ session_id?: string;
+ session_name?: string;
+ session_type?: string;
+ agent_name?: string;
+ workspace_id?: string;
+ workspace?: string;
+ acp_session_id?: string;
+ state?: string;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+ environment_id?: string;
+ backend?: string;
+ profile?: string;
+ instance_id?: string;
+ runtime_root?: string;
+ runtime_additional_dirs?: string[];
+}
+
+export interface EnvironmentStopPatch {
+ deny?: boolean;
+ deny_reason?: string;
+}
+
+export interface EnvironmentStopPayload {
+ event: HookEvent;
+ timestamp: ISODateTime;
+ session_id?: string;
+ session_name?: string;
+ session_type?: string;
+ agent_name?: string;
+ workspace_id?: string;
+ workspace?: string;
+ acp_session_id?: string;
+ state?: string;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+ environment_id?: string;
+ backend?: string;
+ profile?: string;
+ instance_id?: string;
+ runtime_root?: string;
+ stop_reason?: string;
+ will_destroy?: boolean;
+ denied?: boolean;
+ deny_reason?: string;
+}
+
+export type EnvironmentSyncAfterPatch = Record;
+
+export interface EnvironmentSyncAfterPayload {
+ event: HookEvent;
+ timestamp: ISODateTime;
+ session_id?: string;
+ session_name?: string;
+ session_type?: string;
+ agent_name?: string;
+ workspace_id?: string;
+ workspace?: string;
+ acp_session_id?: string;
+ state?: string;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+ environment_id?: string;
+ backend?: string;
+ profile?: string;
+ instance_id?: string;
+ runtime_root?: string;
+ direction?: string;
+ reason?: string;
+ files_synced?: number;
+ bytes_transferred?: number;
+ duration_ms?: number;
+ errors?: string[];
+}
+
+export interface EnvironmentSyncBeforePatch {
+ deny?: boolean;
+ deny_reason?: string;
+ exclude_patterns?: string[];
+}
+
+export interface EnvironmentSyncBeforePayload {
+ event: HookEvent;
+ timestamp: ISODateTime;
+ session_id?: string;
+ session_name?: string;
+ session_type?: string;
+ agent_name?: string;
+ workspace_id?: string;
+ workspace?: string;
+ acp_session_id?: string;
+ state?: string;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+ environment_id?: string;
+ backend?: string;
+ profile?: string;
+ instance_id?: string;
+ runtime_root?: string;
+ direction?: string;
+ reason?: string;
+ file_count?: number;
+ exclude_patterns?: string[];
+ denied?: boolean;
+ deny_reason?: string;
+}
+
export interface EventPostRecordPatch {
labels?: Record;
}
@@ -764,6 +977,10 @@ export interface HookMatcher {
workspace_id?: string;
workspace_root?: string;
session_type?: string;
+ environment_id?: string;
+ environment_backend?: string;
+ environment_profile?: string;
+ sync_direction?: string;
input_class?: string;
acp_event_type?: string;
turn_id?: string;
@@ -884,10 +1101,16 @@ export interface InitializeBridgeRuntime {
managed_instances?: InitializeBridgeManagedInstance[];
}
+export type ResourceKind = string;
+
+export type ResourceScopeKind = string;
+
export interface InitializeCapabilities {
provides: string[];
granted_actions: HostAPIMethod[];
granted_security: string[];
+ granted_resource_kinds: ResourceKind[];
+ granted_resource_scopes: ResourceScopeKind[];
}
export interface InitializeExtension {
@@ -920,6 +1143,7 @@ export interface InitializeRequest {
protocol_version: string;
supported_protocol_versions: string[];
agh_version: string;
+ session_nonce: string;
extension: InitializeExtension;
capabilities: InitializeCapabilities;
methods: InitializeMethods;
@@ -928,7 +1152,6 @@ export interface InitializeRequest {
export interface InitializeSupports {
health_check: boolean;
- provide_tools: boolean;
}
export interface InitializeResponse {
@@ -1390,6 +1613,60 @@ export interface PromptPayload {
context_blocks?: ContextBlock[];
}
+export interface ResourceGetParams {
+ kind: ResourceKind;
+ id: string;
+}
+
+export type ResourceOwnerKind = string;
+
+export interface ResourceOwner {
+ kind: ResourceOwnerKind;
+ id: string;
+}
+
+export interface ResourceScope {
+ kind: ResourceScopeKind;
+ id?: string;
+}
+
+export type ResourceSourceKind = string;
+
+export interface ResourceSource {
+ kind: ResourceSourceKind;
+ id: string;
+}
+
+export interface ResourceRecord {
+ kind: ResourceKind;
+ id: string;
+ version: number;
+ scope: ResourceScope;
+ owner: ResourceOwner;
+ source: ResourceSource;
+ spec: JSONValue;
+ created_at: ISODateTime;
+ updated_at: ISODateTime;
+}
+
+export interface ResourceSnapshotRecord {
+ kind: ResourceKind;
+ id: string;
+ scope: ResourceScope;
+ spec: JSONValue;
+}
+
+export interface ResourcesListParams {
+ kind?: ResourceKind;
+ scope?: ResourceScope;
+ limit?: number;
+}
+
+export interface ResourcesSnapshotParams {
+ source_version: number;
+ records: ResourceSnapshotRecord[];
+}
+
export interface Run {
id: string;
job_id?: string;
@@ -2079,6 +2356,11 @@ export interface HookPayloadByEvent {
"session.post_resume": SessionPostResumePayload;
"session.pre_stop": SessionPreStopPayload;
"session.post_stop": SessionPostStopPayload;
+ "environment.prepare": EnvironmentPreparePayload;
+ "environment.ready": EnvironmentReadyPayload;
+ "environment.sync.before": EnvironmentSyncBeforePayload;
+ "environment.sync.after": EnvironmentSyncAfterPayload;
+ "environment.stop": EnvironmentStopPayload;
"input.pre_submit": InputPreSubmitPayload;
"prompt.post_assemble": PromptPayload;
"event.pre_record": EventPreRecordPayload;
@@ -2115,6 +2397,11 @@ export interface HookPatchByEvent {
"session.post_resume": SessionPostResumePatch;
"session.pre_stop": SessionPreStopPatch;
"session.post_stop": SessionPostStopPatch;
+ "environment.prepare": EnvironmentPreparePatch;
+ "environment.ready": EnvironmentReadyPatch;
+ "environment.sync.before": EnvironmentSyncBeforePatch;
+ "environment.sync.after": EnvironmentSyncAfterPatch;
+ "environment.stop": EnvironmentStopPatch;
"input.pre_submit": InputPreSubmitPatch;
"prompt.post_assemble": PromptPatch;
"event.pre_record": EventPreRecordPatch;
@@ -2169,6 +2456,18 @@ export interface HostAPIMethodMap {
params: SessionEventsParams;
result: SessionEvent[];
};
+ "environment/list": {
+ params: EnvironmentListParams | undefined;
+ result: EnvironmentListResult;
+ };
+ "environment/info": {
+ params: EnvironmentInfoParams;
+ result: EnvironmentInfoResult;
+ };
+ "environment/exec": {
+ params: EnvironmentExecParams;
+ result: EnvironmentExecResult;
+ };
"memory/recall": {
params: MemoryRecallParams;
result: MemoryRecallEntry[];
@@ -2305,6 +2604,18 @@ export interface HostAPIMethodMap {
params: TaskRunCancelParams;
result: TaskRun;
};
+ "resources/list": {
+ params: ResourcesListParams | undefined;
+ result: ResourceRecord[];
+ };
+ "resources/get": {
+ params: ResourceGetParams;
+ result: ResourceRecord;
+ };
+ "resources/snapshot": {
+ params: ResourcesSnapshotParams;
+ result: EmptyResult;
+ };
"bridges/instances/list": {
params: undefined;
result: BridgeInstance[];
diff --git a/sdk/typescript/src/host-api.test.ts b/sdk/typescript/src/host-api.test.ts
index b8c47e4b7..6a074576f 100644
--- a/sdk/typescript/src/host-api.test.ts
+++ b/sdk/typescript/src/host-api.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
-import { CapabilityDeniedError, NotInitializedError, RateLimitedError } from "./errors.js";
+import {
+ CapabilityDeniedError,
+ InvalidParamsError,
+ NotInitializedError,
+ RateLimitedError,
+ RPCError,
+} from "./errors.js";
import { HostAPI } from "./host-api.js";
import { createMockTransportPair } from "./testing/mock-transport.js";
@@ -260,6 +266,162 @@ describe("HostAPI", () => {
});
});
+ it("resources helpers validate payload shape and send snapshot requests", async () => {
+ const pair = createMockTransportPair();
+ const host = new HostAPI(pair.extension, { isReady: () => true });
+
+ pair.host.handle("resources/list", async params => {
+ expect(params).toEqual({
+ kind: "tool",
+ scope: { kind: "workspace", id: "ws-1" },
+ limit: 5,
+ });
+ return [
+ {
+ kind: "tool",
+ id: "grep",
+ version: 2,
+ scope: { kind: "workspace", id: "ws-1" },
+ owner: { kind: "extension", id: "resource-ext" },
+ source: { kind: "extension", id: "resource-ext" },
+ spec: { command: "rg" },
+ created_at: "2026-04-15T12:00:00.000Z",
+ updated_at: "2026-04-15T12:01:00.000Z",
+ },
+ ];
+ });
+ pair.host.handle("resources/get", async params => {
+ expect(params).toEqual({ kind: "tool", id: "grep" });
+ return {
+ kind: "tool",
+ id: "grep",
+ version: 2,
+ scope: { kind: "workspace", id: "ws-1" },
+ owner: { kind: "extension", id: "resource-ext" },
+ source: { kind: "extension", id: "resource-ext" },
+ spec: { command: "rg" },
+ created_at: "2026-04-15T12:00:00.000Z",
+ updated_at: "2026-04-15T12:01:00.000Z",
+ };
+ });
+ pair.host.handle("resources/snapshot", async params => {
+ expect(params).toEqual({
+ source_version: 3,
+ records: [
+ {
+ kind: "tool",
+ id: "grep",
+ scope: { kind: "workspace", id: "ws-1" },
+ spec: { command: "rg" },
+ },
+ ],
+ });
+ return {};
+ });
+
+ await expect(
+ host.resources.list({
+ kind: "tool",
+ scope: { kind: "workspace", id: "ws-1" },
+ limit: 5,
+ })
+ ).resolves.toHaveLength(1);
+ await expect(host.resources.get({ kind: "tool", id: "grep" })).resolves.toMatchObject({
+ id: "grep",
+ version: 2,
+ });
+ await expect(
+ host.resources.snapshot({
+ source_version: 3,
+ records: [
+ {
+ kind: "tool",
+ id: "grep",
+ scope: { kind: "workspace", id: "ws-1" },
+ spec: { command: "rg" },
+ },
+ ],
+ })
+ ).resolves.toEqual({});
+
+ await expect(
+ host.resources.list({ scope: { kind: "workspace", id: "" } })
+ ).rejects.toBeInstanceOf(InvalidParamsError);
+ await expect(host.resources.get({ kind: "", id: "grep" })).rejects.toBeInstanceOf(
+ InvalidParamsError
+ );
+ await expect(
+ host.resources.snapshot({
+ source_version: 0,
+ records: [],
+ })
+ ).rejects.toBeInstanceOf(InvalidParamsError);
+ });
+
+ it("resources helpers surface 403, 409, 413, and 429 protocol errors", async () => {
+ const pair = createMockTransportPair();
+ const host = new HostAPI(pair.extension, { isReady: () => true });
+
+ pair.host.handle("resources/get", async () => {
+ throw new RPCError(403, "Forbidden", { error: "same-source only" });
+ });
+ pair.host.handle("resources/list", async () => {
+ throw new RPCError(409, "Conflict", { error: "stale source_version" });
+ });
+ pair.host.handle("resources/snapshot", async params => {
+ const request = params as { source_version: number };
+ switch (request.source_version) {
+ case 4:
+ throw new RPCError(413, "Payload too large", { error: "snapshot too large" });
+ case 5:
+ throw new RPCError(429, "Rate limited", { error: "snapshot queued" });
+ default:
+ return {};
+ }
+ });
+
+ await expect(host.resources.get({ kind: "tool", id: "grep" })).rejects.toMatchObject({
+ code: 403,
+ message: "Forbidden",
+ });
+ await expect(host.resources.list()).rejects.toMatchObject({
+ code: 409,
+ message: "Conflict",
+ });
+ await expect(
+ host.resources.snapshot({
+ source_version: 4,
+ records: [
+ {
+ kind: "tool",
+ id: "grep",
+ scope: { kind: "workspace", id: "ws-1" },
+ spec: { command: "rg" },
+ },
+ ],
+ })
+ ).rejects.toMatchObject({
+ code: 413,
+ message: "Payload too large",
+ });
+ await expect(
+ host.resources.snapshot({
+ source_version: 5,
+ records: [
+ {
+ kind: "tool",
+ id: "grep",
+ scope: { kind: "workspace", id: "ws-1" },
+ spec: { command: "rg" },
+ },
+ ],
+ })
+ ).rejects.toMatchObject({
+ code: 429,
+ message: "Rate limited",
+ });
+ });
+
it("rejects calls before the session is ready", async () => {
const pair = createMockTransportPair();
const host = new HostAPI(pair.extension, { isReady: () => false });
diff --git a/sdk/typescript/src/host-api.ts b/sdk/typescript/src/host-api.ts
index ba031ef0d..3c4a3482e 100644
--- a/sdk/typescript/src/host-api.ts
+++ b/sdk/typescript/src/host-api.ts
@@ -1,4 +1,4 @@
-import { NotInitializedError } from "./errors.js";
+import { InvalidParamsError, NotInitializedError } from "./errors.js";
import type {
BridgeInstance,
BridgeInstanceTargetParams,
@@ -9,6 +9,10 @@ import type {
InboundMessageEnvelope,
ObserveEventsParams,
ObserveHealth,
+ ResourceGetParams,
+ ResourceRecord,
+ ResourcesListParams,
+ ResourcesSnapshotParams,
SessionCreateResult,
SessionEvent,
SessionPromptResult,
@@ -84,6 +88,12 @@ export class HostAPI {
reportState: (params: BridgesInstancesReportStateParams) => Promise;
};
+ public readonly resources: {
+ list: (params?: ResourcesListParams) => Promise;
+ get: (params: ResourceGetParams) => Promise;
+ snapshot: (params: ResourcesSnapshotParams) => Promise>;
+ };
+
public constructor(
private readonly transport: HostAPITransport,
options: HostAPIOptions = {}
@@ -120,6 +130,21 @@ export class HostAPI {
get: async params => await this.request("bridges/instances/get", params),
reportState: async params => await this.request("bridges/instances/report_state", params),
};
+
+ this.resources = {
+ list: async params => {
+ validateResourcesListParams(params);
+ return await this.request("resources/list", params);
+ },
+ get: async params => {
+ validateResourceGetParams(params);
+ return await this.request("resources/get", params);
+ },
+ snapshot: async params => {
+ validateResourcesSnapshotParams(params);
+ return await this.request("resources/snapshot", params);
+ },
+ };
}
public async request(
@@ -129,3 +154,85 @@ export class HostAPI {
return await callHostMethod(this.transport, method, params, this.isReady);
}
}
+
+function validateResourcesListParams(params: ResourcesListParams | undefined): void {
+ if (params === undefined) {
+ return;
+ }
+ if (!isRecord(params)) {
+ throw new InvalidParamsError("resources.list params must be an object");
+ }
+ if (params.kind !== undefined) {
+ assertNonEmptyString(params.kind, "resources.list kind");
+ }
+ if (params.scope !== undefined) {
+ validateResourceScope(params.scope, "resources.list scope");
+ }
+ const limit = params.limit;
+ if (
+ limit !== undefined &&
+ limit !== null &&
+ (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0)
+ ) {
+ throw new InvalidParamsError("resources.list limit must be a non-negative integer");
+ }
+}
+
+function validateResourceGetParams(params: ResourceGetParams): void {
+ if (!isRecord(params)) {
+ throw new InvalidParamsError("resources.get params must be an object");
+ }
+ assertNonEmptyString(params.kind, "resources.get kind");
+ assertNonEmptyString(params.id, "resources.get id");
+}
+
+function validateResourcesSnapshotParams(params: ResourcesSnapshotParams): void {
+ if (!isRecord(params)) {
+ throw new InvalidParamsError("resources.snapshot params must be an object");
+ }
+ if (!Number.isInteger(params.source_version) || params.source_version <= 0) {
+ throw new InvalidParamsError("resources.snapshot source_version must be a positive integer");
+ }
+ if (!Array.isArray(params.records)) {
+ throw new InvalidParamsError("resources.snapshot records must be an array");
+ }
+
+ for (const [index, record] of params.records.entries()) {
+ if (!isRecord(record)) {
+ throw new InvalidParamsError(`resources.snapshot records[${index}] must be an object`);
+ }
+ assertNonEmptyString(record.kind, `resources.snapshot records[${index}].kind`);
+ assertNonEmptyString(record.id, `resources.snapshot records[${index}].id`);
+ validateResourceScope(record.scope, `resources.snapshot records[${index}].scope`);
+ if (!Object.prototype.hasOwnProperty.call(record, "spec") || record.spec === undefined) {
+ throw new InvalidParamsError(`resources.snapshot records[${index}].spec is required`);
+ }
+ }
+}
+
+function validateResourceScope(scope: unknown, field: string): void {
+ if (!isRecord(scope)) {
+ throw new InvalidParamsError(`${field} must be an object`);
+ }
+ if (scope.kind !== "global" && scope.kind !== "workspace") {
+ throw new InvalidParamsError(`${field}.kind must be "global" or "workspace"`);
+ }
+
+ const id = typeof scope.id === "string" ? scope.id.trim() : "";
+ if (scope.kind === "global" && id !== "") {
+ throw new InvalidParamsError(`${field}.id must be empty for global scope`);
+ }
+ if (scope.kind === "workspace" && id === "") {
+ throw new InvalidParamsError(`${field}.id is required for workspace scope`);
+ }
+}
+
+function assertNonEmptyString(value: unknown, field: string): void {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new InvalidParamsError(`${field} is required`);
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts
index 59452caa9..3f61eaf53 100644
--- a/sdk/typescript/src/index.ts
+++ b/sdk/typescript/src/index.ts
@@ -130,7 +130,6 @@ export type {
PromptPatch,
PromptPayload,
ProtocolVersion,
- ProvideToolsResult,
RoutingKey,
RoutingPolicy,
ResourcesConfig,
@@ -153,7 +152,6 @@ export type {
SessionPreStopPatch,
SessionPreStopPayload,
SessionPromptResult,
- SessionState,
SessionStatus,
SessionSummary,
SessionTargetParams,
@@ -184,3 +182,4 @@ export type {
TurnStartPatch,
TurnStartPayload,
} from "./types.js";
+export type { State as SessionState } from "./types.js";
diff --git a/sdk/typescript/src/integration.test.ts b/sdk/typescript/src/integration.test.ts
index b446ab28a..3fa9a57b4 100644
--- a/sdk/typescript/src/integration.test.ts
+++ b/sdk/typescript/src/integration.test.ts
@@ -97,11 +97,14 @@ describe("SDK integration", () => {
protocol_version: "1",
supported_protocol_versions: ["1"],
agh_version: "0.5.0",
+ session_nonce: "integration-nonce",
extension: { name: "integration-ext", version: "0.1.0", source_tier: "user" },
capabilities: {
provides: ["memory.backend"],
granted_actions: ["sessions/list"],
granted_security: ["memory.read", "memory.write", "session.read"],
+ granted_resource_kinds: [],
+ granted_resource_scopes: [],
},
methods: {
daemon_requests: ["health_check", "shutdown"],
diff --git a/sdk/typescript/src/testing/harness.ts b/sdk/typescript/src/testing/harness.ts
index 0c4ecf115..f91d3a48d 100644
--- a/sdk/typescript/src/testing/harness.ts
+++ b/sdk/typescript/src/testing/harness.ts
@@ -18,6 +18,9 @@ export interface HarnessLoadOptions {
provides?: string[];
grantedActions?: string[];
grantedSecurity?: string[];
+ grantedResourceKinds?: string[];
+ grantedResourceScopes?: ("global" | "workspace")[];
+ sessionNonce?: string;
capabilities?: string[];
daemonRequests?: string[];
extensionServices?: string[];
@@ -47,7 +50,7 @@ const DEFAULT_RUNTIME: InitializeRuntime = {
default_hook_timeout_ms: 5_000,
};
-const DAEMON_METHODS = new Set(["execute_hook", "health_check", "shutdown", "provide_tools"]);
+const DAEMON_METHODS = new Set(["execute_hook", "health_check", "shutdown"]);
export class TestHarness {
private readonly mockedHostHandlers = new Map<
@@ -188,6 +191,7 @@ export class TestHarness {
protocol_version: "1",
supported_protocol_versions: ["1" satisfies ProtocolVersion],
agh_version: options.aghVersion ?? "0.5.0",
+ session_nonce: options.sessionNonce ?? "session-nonce-test",
extension: {
name: definition.name,
version: definition.version,
@@ -197,6 +201,8 @@ export class TestHarness {
provides: options.provides ?? [...requestedProvides],
granted_actions: (options.grantedActions ?? [...requestedActions]) as HostAPIMethod[],
granted_security: options.grantedSecurity ?? options.capabilities ?? [...requestedSecurity],
+ granted_resource_kinds: options.grantedResourceKinds ?? [],
+ granted_resource_scopes: options.grantedResourceScopes ?? [],
},
methods: {
daemon_requests: daemonRequests,
diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts
index f62cd4009..5096804b7 100644
--- a/sdk/typescript/src/types.ts
+++ b/sdk/typescript/src/types.ts
@@ -7,7 +7,6 @@ import type {
HookPatchByEvent,
HookPayloadByEvent,
HostAPIMethod,
- Tool,
} from "./generated/contracts.js";
export * from "./base-types.js";
@@ -42,10 +41,17 @@ export interface MCPServerConfig {
env?: Record;
}
+export interface ToolConfig {
+ description?: string;
+ input_schema?: JSONValue;
+ read_only?: boolean;
+}
+
export interface ResourcesConfig {
skills?: string[];
agents?: string[];
hooks?: HookConfig[];
+ tools?: Record;
mcp_servers?: Record;
}
@@ -94,10 +100,6 @@ export interface HealthCheckResult {
details?: Record;
}
-export interface ProvideToolsResult {
- tools: Tool[];
-}
-
export interface HookInvocation {
name: string;
event: TEvent;
diff --git a/web/src/generated/agh-openapi.d.ts b/web/src/generated/agh-openapi.d.ts
index e2f0f2633..e65a8aa46 100644
--- a/web/src/generated/agh-openapi.d.ts
+++ b/web/src/generated/agh-openapi.d.ts
@@ -731,6 +731,59 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/resources": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List desired-state resources on the local operator control plane */
+ get: operations["listResources"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/resources/{kind}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List one desired-state resource kind on the local operator control plane */
+ get: operations["listResourcesByKind"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/resources/{kind}/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read one desired-state resource on the local operator control plane */
+ get: operations["getResource"];
+ /** Create or replace one desired-state resource on the local operator control plane */
+ put: operations["putResource"];
+ post?: never;
+ /** Delete one desired-state resource on the local operator control plane */
+ delete: operations["deleteResource"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/sessions": {
parameters: {
query?: never;
@@ -5140,6 +5193,11 @@ export interface operations {
| "session.post_resume"
| "session.pre_stop"
| "session.post_stop"
+ | "environment.prepare"
+ | "environment.ready"
+ | "environment.sync.before"
+ | "environment.sync.after"
+ | "environment.stop"
| "input.pre_submit"
| "prompt.post_assemble"
| "event.pre_record"
@@ -5195,10 +5253,14 @@ export interface operations {
compaction_reason?: string;
compaction_strategy?: string;
decision_class?: string;
+ environment_backend?: string;
+ environment_id?: string;
+ environment_profile?: string;
input_class?: string;
message_delta_type?: string;
message_role?: string;
session_type?: string;
+ sync_direction?: string;
tool_name?: string;
tool_namespace?: string;
tool_read_only?: boolean | null;
@@ -5347,6 +5409,11 @@ export interface operations {
| "session.post_resume"
| "session.pre_stop"
| "session.post_stop"
+ | "environment.prepare"
+ | "environment.ready"
+ | "environment.sync.before"
+ | "environment.sync.after"
+ | "environment.stop"
| "input.pre_submit"
| "prompt.post_assemble"
| "event.pre_record"
@@ -5934,6 +6001,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -6075,6 +6151,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -6874,6 +6959,582 @@ export interface operations {
};
};
};
+ listResources: {
+ parameters: {
+ query?: {
+ /** @description Filter by resource kind */
+ kind?: string;
+ /** @description Filter by resource scope kind */
+ scope_kind?: "global" | "workspace";
+ /** @description Filter by workspace scope id */
+ scope_id?: string;
+ /** @description Filter by stamped owner kind */
+ owner_kind?: string;
+ /** @description Filter by stamped owner id */
+ owner_id?: string;
+ /** @description Filter by stamped source kind */
+ source_kind?: string;
+ /** @description Filter by stamped source id */
+ source_id?: string;
+ /** @description Maximum number of records to return */
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ records: {
+ /** Format: date-time */
+ created_at: string;
+ id: string;
+ kind: string;
+ owner: {
+ id: string;
+ kind: string;
+ };
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ source: {
+ id: string;
+ kind: string;
+ };
+ spec: unknown;
+ /** Format: date-time */
+ updated_at: string;
+ /** Format: int64 */
+ version: number;
+ }[];
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Invalid resource filter */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ listResourcesByKind: {
+ parameters: {
+ query?: {
+ /** @description Filter by resource scope kind */
+ scope_kind?: "global" | "workspace";
+ /** @description Filter by workspace scope id */
+ scope_id?: string;
+ /** @description Filter by stamped owner kind */
+ owner_kind?: string;
+ /** @description Filter by stamped owner id */
+ owner_id?: string;
+ /** @description Filter by stamped source kind */
+ source_kind?: string;
+ /** @description Filter by stamped source id */
+ source_id?: string;
+ /** @description Maximum number of records to return */
+ limit?: number;
+ };
+ header?: never;
+ path: {
+ /** @description Resource kind */
+ kind: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ records: {
+ /** Format: date-time */
+ created_at: string;
+ id: string;
+ kind: string;
+ owner: {
+ id: string;
+ kind: string;
+ };
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ source: {
+ id: string;
+ kind: string;
+ };
+ spec: unknown;
+ /** Format: date-time */
+ updated_at: string;
+ /** Format: int64 */
+ version: number;
+ }[];
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Invalid resource filter */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ getResource: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Resource kind */
+ kind: string;
+ /** @description Resource id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ record: {
+ /** Format: date-time */
+ created_at: string;
+ id: string;
+ kind: string;
+ owner: {
+ id: string;
+ kind: string;
+ };
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ source: {
+ id: string;
+ kind: string;
+ };
+ spec: unknown;
+ /** Format: date-time */
+ updated_at: string;
+ /** Format: int64 */
+ version: number;
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Invalid resource identifier */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ putResource: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Resource kind */
+ kind: string;
+ /** @description Resource id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ /** @description JSON request body */
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: int64 */
+ expected_version?: number;
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ spec: unknown;
+ };
+ };
+ };
+ responses: {
+ /** @description Updated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ record: {
+ /** Format: date-time */
+ created_at: string;
+ id: string;
+ kind: string;
+ owner: {
+ id: string;
+ kind: string;
+ };
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ source: {
+ id: string;
+ kind: string;
+ };
+ spec: unknown;
+ /** Format: date-time */
+ updated_at: string;
+ /** Format: int64 */
+ version: number;
+ };
+ };
+ };
+ };
+ /** @description Created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ record: {
+ /** Format: date-time */
+ created_at: string;
+ id: string;
+ kind: string;
+ owner: {
+ id: string;
+ kind: string;
+ };
+ scope: {
+ id?: string;
+ /** @enum {string} */
+ kind: "global" | "workspace";
+ };
+ source: {
+ id: string;
+ kind: string;
+ };
+ spec: unknown;
+ /** Format: date-time */
+ updated_at: string;
+ /** Format: int64 */
+ version: number;
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Conflict */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Payload too large */
+ 413: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Invalid resource payload */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Rate limited */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ deleteResource: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Resource kind */
+ kind: string;
+ /** @description Resource id */
+ id: string;
+ };
+ cookie?: never;
+ };
+ /** @description JSON request body */
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: int64 */
+ expected_version: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Deleted */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Resource not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Conflict */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Invalid delete request */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Rate limited */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ error: string;
+ };
+ };
+ };
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
listSessions: {
parameters: {
query?: {
@@ -6904,6 +7565,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -6997,6 +7667,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -7104,6 +7783,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -7494,6 +8182,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -11168,6 +11865,7 @@ export interface operations {
/** Format: date-time */
created_at: string;
default_agent?: string;
+ environment_ref?: string;
id: string;
name: string;
root_dir: string;
@@ -11209,6 +11907,7 @@ export interface operations {
"application/json": {
add_dirs?: string[];
default_agent?: string;
+ environment_ref?: string;
name?: string;
root_dir: string;
};
@@ -11227,6 +11926,7 @@ export interface operations {
/** Format: date-time */
created_at: string;
default_agent?: string;
+ environment_ref?: string;
id: string;
name: string;
root_dir: string;
@@ -11305,6 +12005,7 @@ export interface operations {
/** Format: date-time */
created_at: string;
default_agent?: string;
+ environment_ref?: string;
id: string;
name: string;
root_dir: string;
@@ -11402,6 +12103,15 @@ export interface operations {
channel?: string;
/** Format: date-time */
created_at: string;
+ environment?: {
+ backend?: string;
+ environment_id?: string;
+ instance_id?: string;
+ last_sync_error?: string;
+ profile?: string;
+ provider_state_json?: unknown;
+ state?: string;
+ } | null;
id: string;
name?: string;
/** @enum {string} */
@@ -11434,6 +12144,7 @@ export interface operations {
/** Format: date-time */
created_at: string;
default_agent?: string;
+ environment_ref?: string;
id: string;
name: string;
root_dir: string;
@@ -11538,6 +12249,7 @@ export interface operations {
"application/json": {
add_dirs: string[] | null;
default_agent: string | null;
+ environment_ref: string | null;
name: string | null;
};
};
@@ -11555,6 +12267,7 @@ export interface operations {
/** Format: date-time */
created_at: string;
default_agent?: string;
+ environment_ref?: string;
id: string;
name: string;
root_dir: string;