Setup repo: migrate architecture specs, code stubs, and tasks from alkhub_ts
Copy architecture docs, ADRs, storage domain specs, research, reviews, and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for standalone @alkdev/hub repo structure (src/ not packages/hub/). Sanitize all sensitive information: - Replace private IPs (10.0.0.1) with localhost defaults - Remove internal server hostnames (dev1, ns528096) - Replace /workspace/ private paths with npm package references - Remove hardcoded credentials from examples - Rewrite infrastructure.md without private network details Add Deno project scaffolding: deno.json (pinned deps), .gitignore, AGENTS.md, entry point. Migrate existing code stubs (crypto, config types, logger) with updated import paths.
This commit is contained in:
191
docs/architecture/operations.md
Normal file
191
docs/architecture/operations.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Operations System
|
||||
|
||||
## Overview
|
||||
|
||||
The operations system is the universal abstraction for all work in the alk.dev platform. Every API endpoint, agent action, coordination tool, and MCP tool is an operation with typed input/output schemas, access control metadata, and a handler function.
|
||||
|
||||
**Package**: `@alkdev/operations` (npm)
|
||||
|
||||
## Core Components
|
||||
|
||||
### Core Types (`operations/types.ts`)
|
||||
|
||||
- `OperationType` — QUERY (read-only), MUTATION (write), SUBSCRIPTION (async generator)
|
||||
- `OperationSpec` — serializable, hashable subset (name, namespace, version, type, description, tags, inputSchema, outputSchema, errorSchemas, accessControl, \_meta)
|
||||
- `IOperationDefinition` — extends `OperationSpec` with runtime `handler`
|
||||
- `OperationContext` — metadata, requestId, parentRequestId, identity, env
|
||||
- `AccessControl` — requiredScopes (all match), requiredScopesAny (any match), resourceType, resourceAction. See below.
|
||||
- `ResponseEnvelope<T>` — universal result wrapper with source tracking (local/http/mcp). All `execute()` and `env` functions return `ResponseEnvelope<T>`.
|
||||
- `CallError` / `InfrastructureErrorCode` — structured error codes: `OPERATION_NOT_FOUND`, `ACCESS_DENIED`, `VALIDATION_ERROR`, `TIMEOUT`, `ABORTED`, `EXECUTION_ERROR`, `UNKNOWN_ERROR`.
|
||||
|
||||
### Registry (`operations/registry.ts`)
|
||||
|
||||
- Register by `{namespace}.{name}` key
|
||||
- `register()` now accepts `OperationSpec & { handler? }` (handler can be registered separately)
|
||||
- `registerSpec()` / `registerHandler()` — separate spec and handler registration
|
||||
- `execute()` returns `Promise<ResponseEnvelope<TOutput>>` (not `Promise<TOutput>`)
|
||||
- Constructor accepts optional `SchemaAdapter` for Zod/Valibot conversion
|
||||
- Access control is enforced in the registry (via `enforceAccess`)
|
||||
- Validate input before handler execution
|
||||
- Warn on output schema mismatch (don't throw)
|
||||
- `getSpec()` / `getAllSpecs()` for serializable specs
|
||||
|
||||
### Scanner (`operations/scanner.ts`)
|
||||
|
||||
- Recursive filesystem scan for `.ts` operation definitions
|
||||
- `scanOperations(dirPath, fs)` — takes an abstracted `ScannerFS` interface, not `Deno.readDir` directly
|
||||
- `ScannerFS { readdir(path): AsyncIterable, cwd(): string }` — inject Deno or Node adapter
|
||||
- Auto-discovery and registration
|
||||
- Validates against `OperationSpecSchema`, not `OperationDefinition`
|
||||
|
||||
### Env Builder (`operations/env.ts`)
|
||||
|
||||
- `buildEnv()` creates namespace-keyed `OperationEnv` for nested calls
|
||||
- Direct mode: `buildEnv({ registry, context })` → env functions call `registry.execute()` directly
|
||||
- `buildEnv` no longer takes a `callMap` parameter
|
||||
- Sets `trusted: true` on nested context (bypasses access control for internal calls)
|
||||
- Env functions return `Promise<ResponseEnvelope>`, callers use `unwrap(envelope)` or `envelope.data`
|
||||
- Filters SUBSCRIPTION operations out of env
|
||||
|
||||
### FromSchema (`operations/from_schema.ts`)
|
||||
|
||||
- JSON Schema → TypeBox `TSchema` converter
|
||||
- Handles allOf, anyOf, oneOf, enum, object, tuple, array, const, $ref, primitives
|
||||
|
||||
### Schema Adapters (`@alkdev/operations/from-typemap`)
|
||||
|
||||
The `SchemaAdapter` pattern converts non-TypeBox schemas to TypeBox at registration time:
|
||||
|
||||
```ts
|
||||
import { zodAdapter, valibotAdapter } from "@alkdev/operations/from-typemap"
|
||||
|
||||
const registry = new OperationRegistry({ schemaAdapter: zodAdapter() })
|
||||
// or: { schemaAdapter: valibotAdapter() }
|
||||
// or: { schemaAdapter: defaultAdapter } // TypeBox only (default)
|
||||
```
|
||||
|
||||
The `SchemaAdapter` interface has `toTypeBox(schema)` and optional `init()`. Zod and Valibot adapters use dynamic import of `@alkdev/typemap` and check for `~standard` vendor property for auto-detection.
|
||||
|
||||
`@alkdev/typemap` is an optional peer dependency — it's only loaded when a Zod or Valibot schema is actually encountered. Spoke authors using TypeBox directly have no extra dependencies. Non-TypeScript spokes send JSON Schema over the wire, which the hub converts via `FromSchema()`.
|
||||
|
||||
**See ADR-013** for the full decision and trade-offs.
|
||||
|
||||
### FromOpenAPI (`operations/from_openapi.ts`)
|
||||
|
||||
- **Key piece**: generates `IOperationDefinition[]` from OpenAPI specs
|
||||
- Detects `text/event-stream` responses as SUBSCRIPTION type
|
||||
- Auto-generates HTTP fetch handlers with path/query/body param routing
|
||||
- Supports bearer, apiKey, basic auth
|
||||
- **Use case**: import opencode's OpenAPI spec → instant typed client operations
|
||||
|
||||
### MCP Wrapper (`mcp/wrapper.ts`, `mcp/loader.ts`)
|
||||
|
||||
- `createMCPClient` connects to MCP servers (stdio or HTTP)
|
||||
- MCP tools → `IOperationDefinition[]` with auto-generated handlers
|
||||
- `MCPClientLoader` manages multiple MCP client connections
|
||||
- **Use case**: connect to external MCP servers (websearch, etc.) and wrap as operations
|
||||
|
||||
### ResponseEnvelope
|
||||
|
||||
All `execute()` calls and `env` functions return `ResponseEnvelope<T>`:
|
||||
|
||||
```ts
|
||||
interface ResponseEnvelope<T> {
|
||||
data: T
|
||||
meta: ResponseMeta // source: "local" | "http" | "mcp", timestamps, status codes
|
||||
}
|
||||
```
|
||||
|
||||
Factory functions: `localEnvelope(data, operationId)`, `httpEnvelope(data, meta)`, `mcpEnvelope(data, meta)`. Use `unwrap(envelope)` to extract `.data` or `isResponseEnvelope(value)` to type-guard.
|
||||
|
||||
### Access Control
|
||||
|
||||
`checkAccess(accessControl, identity)` — boolean check. `enforceAccess(accessControl, identity, operationId, trusted?)` — throws `CallError` on denial. The `trusted: true` flag bypasses all access checks (set by `buildEnv` on nested calls).
|
||||
|
||||
### CallError
|
||||
|
||||
`CallError` extends `Error` with `code` and `details`. `InfrastructureErrorCode` enum provides standard error codes. `mapError(error, errorSchemas?)` matches thrown errors against declared `errorSchemas`.
|
||||
|
||||
## Open Issues
|
||||
|
||||
### Call Protocol Integration
|
||||
|
||||
Operations use `buildEnv()` which supports direct mode (see call-graph.md):
|
||||
|
||||
- **Direct mode**: `buildEnv({ registry, context })` → env functions call `registry.execute()`
|
||||
|
||||
The call protocol (PendingRequestMap, CallHandler) is part of `@alkdev/operations`. It provides call graph tracking, abort cascading, and structured error handling across all transports. See call-graph.md for the full spec.
|
||||
|
||||
## How It Connects to Everything Else
|
||||
|
||||
```
|
||||
Hub HTTP API routes ──→ registry.execute("namespace.operation", input, ctx)
|
||||
│
|
||||
MCP server tools ──→ registry.execute(...)
|
||||
│
|
||||
FromOpenAPI ops ──→ fetch(opencode container REST API)
|
||||
│
|
||||
MCP client tools ──→ MCPClientLoader → registry.execute(...)
|
||||
│
|
||||
Agent session LLM ──→ tool calls with JSON Schema → registry.execute(...)
|
||||
```
|
||||
|
||||
All paths funnel into the same registry. Access control, validation, and error handling are consistent regardless of entry point.
|
||||
|
||||
## Access Control Model
|
||||
|
||||
Authentication uses [keypal](https://npmjs.com/package/keypal) for API key management. keypal verifies bearer tokens and provides a two-tier scope model:
|
||||
|
||||
1. **Global scopes**: flat string array (e.g., `["read", "write", "admin"]`)
|
||||
2. **Resource-scoped permissions**: `Record<string, string[]>` keyed by `"type:id"` (e.g., `{ "project:abc": ["read", "write"] }`)
|
||||
|
||||
### Identity
|
||||
|
||||
The `Identity` type derives from keypal's `ApiKeyMetadata`:
|
||||
|
||||
```ts
|
||||
interface Identity {
|
||||
id: string // keypal ownerId
|
||||
scopes: string[] // global scopes from keypal
|
||||
resources?: Record<string, string[]> // resource-scoped permissions, key format: "type:id"
|
||||
}
|
||||
```
|
||||
|
||||
"Roles" are scope bundles — a convention on top of scopes, not a separate type. For example, a scope of `"implement"` might grant access to `["dev.fs.read", "dev.fs.write", "dev.bash.exec"]`. Defining which scopes a "role" maps to is a configuration concern, not a type-system concern.
|
||||
|
||||
### AccessControl
|
||||
|
||||
The `AccessControl` definition on each operation declares what permissions are required:
|
||||
|
||||
| Field | Semantics | Example |
|
||||
|-------|-----------|---------|
|
||||
| `requiredScopes` | AND — caller must have ALL of these scopes | `["call"]` — caller can invoke operations |
|
||||
| `requiredScopesAny` | OR — caller must have at least ONE of these scopes | `["admin", "coord.spawn"]` — admin OR can spawn |
|
||||
| `resourceType` | Resource category for resource-scoped checks | `"project"` |
|
||||
| `resourceAction` | Required action on the resource | `"write"` |
|
||||
|
||||
**Enforcement**: The `CallHandler` (see call-graph.md) checks `AccessControl` against `Identity` before dispatching to `registry.execute()`. The registry itself is a pure execution engine — access control lives at the call handler layer.
|
||||
|
||||
**Resource checks**: When `resourceType` + `resourceAction` are set, the check is: does `identity.resources["{resourceType}:{resourceId}"]` include `resourceAction`? This maps directly to keypal's `checkResourceScope(record, resourceType, resourceId, scope)`.
|
||||
|
||||
### Access Control Flow
|
||||
|
||||
```
|
||||
Request → CallHandler receives call.requested with Identity
|
||||
→ Look up operation's AccessControl
|
||||
→ Check requiredScopes (caller has ALL?)
|
||||
→ Check requiredScopesAny (caller has ANY?)
|
||||
→ Check resourceType/resourceAction against identity.resources
|
||||
→ All pass → registry.execute()
|
||||
→ Any fail → call.error with ACCESS_DENIED
|
||||
```
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- **Logger config**: `core/logger/mod.ts` is a stub that only logs the `["logtape", "meta"]` category. Needs proper config for app-level loggers.
|
||||
- **Config**: `core/config/types.ts` has spoke-only config. Needs hub-specific config (postgres, redis, auth).
|
||||
Reference in New Issue
Block a user