Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Thank you for your interest in contributing. Please read the [Code of Conduct](C
git config core.hooksPath .githooks
```

See [docs/development.md](docs/development.md) for daemon bootstrap, Login Items, and database paths.
See [docs/Design.md](docs/Design.md) for architecture notes. Local SQLite lives under the Derrick app group; reset with `./scripts/reset-local-state.sh`.

## Build from the command line

Expand All @@ -48,7 +48,7 @@ Do not commit provisioning profiles (`.mobileprovision`, `.p12`, `.pem`).
- Run `./scripts/verify-no-secrets.sh --staged` before committing.
- Run `./scripts/build.sh test` when you change build-affecting code.
- Keep changes focused; match existing Swift style and module boundaries.
- Update README or ADRs when behavior or architecture changes.
- Update README or `docs/Design.md` when behavior or architecture changes.

## License

Expand Down
6 changes: 0 additions & 6 deletions check_browser_deps.py

This file was deleted.

9 changes: 0 additions & 9 deletions check_playwright.py

This file was deleted.

21 changes: 18 additions & 3 deletions docs/Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ Derrick does not provide many tools to agents. Instead Derrick provides a small
## Structure
This application is Protocol first. All major features must have a Protocol and internally use GoF Design Patterns. No exceptions. The Protocols can be found in the Structure spm.

## Plugins
- Plugins are using the Agent Plugin standard
- Plugins and `script_exec` run in the unified Go worker Docker image (`derrick-worker:go-v1`)
## Guardrail
Guardrail is Derrick's control plane in Structure (`Sources/Guardrail`).

Flow: **Policy evaluates rules → adapters apply `GuardrailDecision` → chokepoints only call those two.**

Naming (no exceptions):

- `Guardrail*` — control-plane types
- `*Evaluating` — rule interpreters (`Request` → `GuardrailDecision`)
- `*Applying` — decision adapters (decision → effect)
- `StoreBacked*Evaluating` — SQLite-backed interpreters in `packages/PolicyRuntime`

- Workflow starts: `StoreBackedWorkflowStartEvaluating` (`workflow_start`) → `WorkflowStartGuardrailApplying` in `WorkflowRuntimeEngine`.
- MCP tools/effectors: `StoreBackedToolInvocationEvaluating` (`tool_invocation`) → `ToolInvocationGuardrailApplying` in the chat pipeline and MCPService.
- Content: `StoreBackedAssistantContentEvaluating` → `AssistantContentGuardrailApplying`.
- HITL: `GuardrailHITLPresenting` (shared); adapters take a presenter, chokepoints do not switch on decisions.
- `WorkflowKind.pluginFactoryEdit` is denied by a Policy rule until editability ships.
- Plugins propose work; they do not authorize control outcomes.
16 changes: 0 additions & 16 deletions fetch_ms.py

This file was deleted.

3 changes: 1 addition & 2 deletions master-todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ In-use lease TTL (default 7 minutes) stays as the anti-hoard cap. No idle TTL (n

### Docs

- [ ] Sweep `docs/`: drop or mark stale ADRs, fix Python-vs-Swift guest, file extractor vs native reads, container recreate-on-handoff, messaging roadmap items that already shipped.
- [ ] Files to revisit: `docs/adr-swift-script-runtime.md`, `docs/development.md`, `docs/messaging-design.md` remaining table, `docs/opensource-plan.md`, `readme.md` if it still implies one-shot containers only.
- [x] Dropped stale ADRs / plan docs; `docs/Design.md` is the remaining architecture note. README/CONTRIBUTING no longer link to deleted files.

### Startup: crawler image build blocks the app

Expand Down
4 changes: 3 additions & 1 deletion packages/DerrickBackend/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let package = Package(
.package(path: "../DBRepository"),
.package(path: "../DockerRunnerXPC"),
.package(path: "../Plugin"),
.package(path: "../PolicyRuntime"),
],
targets: [
.target(
Expand All @@ -24,6 +25,7 @@ let package = Package(
"DBRepository",
"DockerRunnerXPC",
"Plugin",
"PolicyRuntime",
],
path: "Sources/DerrickBackend",
swiftSettings: [
Expand All @@ -32,7 +34,7 @@ let package = Package(
),
.testTarget(
name: "DerrickBackendTests",
dependencies: ["DerrickBackend", "DBRepository", "Plugin", "Structure"],
dependencies: ["DerrickBackend", "DBRepository", "Plugin", "Structure", "PolicyRuntime"],
path: "Tests/DerrickBackendTests",
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import DBRepository
import Foundation
import PolicyRuntime
import Structure

/// Durable workflow coordinator (Process Manager) running inside derrickd.
Expand All @@ -16,6 +17,25 @@ public actor WorkflowRuntimeEngine {
repositoryProvider: @escaping @Sendable () async throws -> DBRepository
) async throws -> WorkflowHandleDTO {
let repo = try await repositoryProvider()
try await DefaultGuardrailPolicySeeds.seedWorkflowStartRulesIfNeeded(
store: repo,
applicationName: DerrickAppSupport.defaultApplicationName
)
let decision = try await StoreBackedWorkflowStartEvaluating(
store: repo,
applicationName: DerrickAppSupport.defaultApplicationName
).evaluate(request)
let hitl = ClosureGuardrailHITLPresenting { [self] presentation in
let approved = await awaitWorkflowStartHITL(
request: request,
repository: repo,
hitl: presentation.hitl
)
return approved
? .approved(editedPayloadJSON: nil, actor: nil)
: .cancelled(actor: nil)
}
try await WorkflowStartGuardrailApplying(hitl: hitl).apply(decision, for: request)
let idempotencyKey = WorkflowRuntimeIdempotency.key(
sessionID: request.sessionID,
kind: request.kind,
Expand Down Expand Up @@ -336,4 +356,57 @@ public actor WorkflowRuntimeEngine {
message: message
)
}

private static let workflowHITLPollNanoseconds: UInt64 = 1_000_000_000
private static let workflowHITLTimeoutNanoseconds: UInt64 = 15 * 60 * 1_000_000_000

/// Present HITL for a workflow_start confirm decision; returns true when approved.
private func awaitWorkflowStartHITL(
request: WorkflowStartRequest,
repository: DBRepository,
hitl: GuardrailHITLRequest
) async -> Bool {
let approvalID = UUID().uuidString
let requiredJSON = (try? JSONEncoder().encode(hitl.requiredFields))
.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
let isJob: Bool = {
if case .job = request.principal { return true }
return false
}()
let row = PendingHITLApprovalRow(
id: approvalID,
turnID: request.turnID ?? request.sessionID,
sessionID: request.sessionID,
toolName: "workflow_start:\(request.kind.rawValue)",
argumentsJSON: request.inputJSON,
requiredFieldsJSON: requiredJSON,
isJobContext: isJob
)
do {
try await repository.insertPendingHITLApproval(row)
} catch {
fputs("[workflow] HITL persist failed: \(error.localizedDescription)\n", stderr)
return false
}
DerrickHITLNotificationSignal.postPoll()

let deadline = Date().addingTimeInterval(
Double(Self.workflowHITLTimeoutNanoseconds) / 1_000_000_000
)
while Date() < deadline {
if Task.isCancelled { return false }
if let decision = try? await repository.fetchPendingHITLApproval(id: approvalID),
decision.status != .pending {
return decision.status == .approved
}
try? await Task.sleep(nanoseconds: Self.workflowHITLPollNanoseconds)
}
try? await repository.resolveHITLApproval(
id: approvalID,
status: .timeout,
editedArgumentsJSON: nil,
actor: "system-timeout"
)
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,66 +123,70 @@ import Testing
)
let repository = DBRepository(configuration: configuration)
_ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret")
try await DefaultGuardrailPolicySeeds.seedWorkflowStartRulesIfNeeded(
store: repository,
applicationName: "ui"
)

let previousMCP = InProcessServiceBridges.mcpCallTool
defer { InProcessServiceBridges.mcpCallTool = previousMCP }

InProcessServiceBridges.mcpCallTool = { request in
switch request.toolName {
case "web.crawl":
let pages: String
if request.argumentsJSON.contains("agent-plugins.org") {
pages = #"{"pages":[{"url":"https://agent-plugins.org/specification","title":"Spec","text":"plugin.json and skills/SKILL.md are required."}]}"#
} else {
pages = #"{"pages":[{"url":"https://api.slack.com/docs","title":"Slack","text":"auth"}]}"#
// Hold the first run in `running` until after the second start (dedupe only matches running).
actor ReleaseGate {
private var released = false
private var waiters: [CheckedContinuation<Void, Never>] = []

func wait() async {
if released { return }
await withCheckedContinuation { (c: CheckedContinuation<Void, Never>) in
waiters.append(c)
}
let outcome = try ToolExecutionOutcome.completed(
output: ToolExecutionOutcome.Output(format: .json, value: pages)
).encodedJSON()
return MCPToolCallResultDTO(
requestID: request.requestID,
ok: true,
isError: false,
text: outcome
)
case "plugin_factory_build":
let receipt = """
{"plugin_id":"slack-connector","version":"1.0.0","content_hash":"abc","review_summary":"ok","secrets":[]}
"""
let outcome = try ToolExecutionOutcome.completed(
output: ToolExecutionOutcome.Output(format: .json, value: receipt)
).encodedJSON()
return MCPToolCallResultDTO(
requestID: request.requestID,
ok: true,
isError: false,
text: outcome
)
default:
return MCPToolCallResultDTO(
requestID: request.requestID,
ok: false,
isError: true,
text: "",
message: "unexpected tool \(request.toolName)"
)
}

func release() {
released = true
for waiter in waiters {
waiter.resume()
}
waiters.removeAll()
}
}
let gate = ReleaseGate()

InProcessServiceBridges.mcpCallTool = { request in
await gate.wait()
return MCPToolCallResultDTO(
requestID: request.requestID,
ok: false,
isError: true,
text: "",
message: "held for dedupe test"
)
}

let inputJSON = try PluginFactoryCreateInput.makeConnector(
vendor: .slack,
scope: .fullSync,
userDescription: "Post alerts."
).encodedJSON()
let request = WorkflowStartRequest(
kind: .pluginFactoryCreate,
sessionID: "session-1",
sessionID: "session-dedupe",
agentID: "ui",
inputJSON: "slack connector",
principal: .agent(sessionID: "session-1", agentID: "ui")
inputJSON: inputJSON,
principal: .agent(sessionID: "session-dedupe", agentID: "ui")
)
let provider: @Sendable () async throws -> DBRepository = { repository }
let first = try await WorkflowRuntimeEngine.shared.startWorkflow(request, repositoryProvider: provider)
// Give the run task a turn to reach the gated MCP call while still running.
try await Task.sleep(nanoseconds: 50_000_000)
let second = try await WorkflowRuntimeEngine.shared.startWorkflow(request, repositoryProvider: provider)
#expect(first.deduplicated == false)
#expect(second.deduplicated == true)
#expect(first.workflowID == second.workflowID)

await gate.release()

var status = WorkflowRunStatus.running
for _ in 0..<50 {
try await Task.sleep(nanoseconds: 100_000_000)
Expand Down

This file was deleted.

Loading
Loading