Feat v0.9.6 stage mode collapse (#80)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 3m4s
CI/CD / build-and-deploy (push) Successful in 1m15s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 3m4s
CI/CD / build-and-deploy (push) Successful in 1m15s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #80.
This commit is contained in:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to Armature are documented here.
|
||||
|
||||
## v0.9.6 — Deprecate stage_type, Collapse stage_mode
|
||||
|
||||
Simplifies the workflow stage classification model by removing
|
||||
redundant fields.
|
||||
|
||||
**stage_type deprecated**
|
||||
|
||||
- No longer validated on input; any value accepted, defaults to "simple"
|
||||
- Existing manifests parsed for backward compatibility
|
||||
- DB column retained; export no longer includes the field
|
||||
- `starlark_hook` presence (not `stage_type`) determines automation
|
||||
|
||||
**stage_mode collapsed (4 → 3 values)**
|
||||
|
||||
- "review" removed as valid mode; mapped to "form" on input
|
||||
- Existing DB rows migrated: review → form
|
||||
- Review surface removed from workflow.html (~110 lines); signoff system
|
||||
in `stage_config.validation` handles review behavior
|
||||
- Valid modes: form, delegated, automated
|
||||
|
||||
**DB migration 018**
|
||||
|
||||
- Postgres: UPDATE + CHECK constraint replacement
|
||||
- SQLite: UPDATE only (CHECK stays broad)
|
||||
|
||||
**Package manifests updated**
|
||||
|
||||
- bug-report-triage, content-approval, employee-onboarding,
|
||||
webhook-notifier: review → form, stage_type removed
|
||||
|
||||
## v0.9.5 — Typed Forms → SDK Primitive
|
||||
|
||||
Promotes the typed form system from a workflow-only model to a reusable
|
||||
|
||||
44
ROADMAP.md
44
ROADMAP.md
@@ -122,11 +122,12 @@ Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
||||
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
|
||||
Manifest `form_template` accepted at package level. 16 new tests.
|
||||
|
||||
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`**
|
||||
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)*
|
||||
|
||||
`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook`
|
||||
presence. Remove from new manifests, keep parsing for backward compat.
|
||||
Collapse `stage_mode` from 4 to 3 values: form / delegated / automated.
|
||||
`stage_type` deprecated (no longer validated, defaults to "simple").
|
||||
`stage_mode` collapsed from 4→3 values: form / delegated / automated.
|
||||
"review" mapped to "form" on input; review surface removed (~110 lines).
|
||||
Migration 018. 4 package manifests updated.
|
||||
|
||||
**v0.9.7 — Full Read/Write Workflow Starlark Module**
|
||||
|
||||
@@ -232,13 +233,31 @@ capability. Zero configuration.
|
||||
|
||||
---
|
||||
|
||||
### v0.14.x — Sidecar Tier + Polish
|
||||
### v0.14.x — Sidecar Tier
|
||||
|
||||
Connect-inward model: sidecars connect TO the kernel (no K8s RBAC, no
|
||||
service mesh, no DNS discovery). Instance sidecars (shared infrastructure)
|
||||
and user sidecars (personal local tools). Design doc:
|
||||
`docs/DESIGN-sidecar-v014x.md`.
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.14.0 | Sidecar Tier |
|
||||
| v0.14.1 | Native Dialog Audit |
|
||||
| v0.14.2 | Stability + Migration Tooling |
|
||||
| v0.14.0 | Sidecar Registry + Auth |
|
||||
| v0.14.1 | Capability Registration + Execution |
|
||||
| v0.14.2 | Kernel API Access + Event Bus |
|
||||
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||
|
||||
---
|
||||
|
||||
### v0.15.x — Polish + Stability
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.15.0 | Native Dialog Audit |
|
||||
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||
|
||||
---
|
||||
|
||||
@@ -265,6 +284,10 @@ Gate criteria:
|
||||
- Headless E2E green on PG + SQLite
|
||||
- `armature-ca.sh` + mTLS deployment guide
|
||||
- Single-binary + Docker + K8s deployment paths documented
|
||||
- Instance sidecar (`armature-embed`) deployed and functional
|
||||
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||
- Token + mTLS sidecar auth both tested
|
||||
- `armature migrate` CLI functional
|
||||
|
||||
---
|
||||
|
||||
@@ -328,3 +351,8 @@ These are candidates, not commitments. Each requires a design doc.
|
||||
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||
| llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. |
|
||||
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery. Works on any deployment target. |
|
||||
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism. |
|
||||
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility. Defense in depth. |
|
||||
| Separate polish (v0.15.x) | Security-critical sidecar infra and quality-of-life polish shouldn't share focus. |
|
||||
|
||||
@@ -127,7 +127,7 @@ STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"Team Review\",
|
||||
\"stage_mode\": \"review\",
|
||||
\"stage_mode\": \"form\",
|
||||
\"audience\": \"team\",
|
||||
\"ordinal\": 1,
|
||||
\"assignment_team_id\": \"${TEAM_ID}\"
|
||||
|
||||
@@ -125,7 +125,7 @@ REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Manager Review",
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"ordinal": 1
|
||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||
|
||||
|
||||
922
docs/DESIGN-sidecar-v014x.md
Normal file
922
docs/DESIGN-sidecar-v014x.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# DESIGN: Sidecar Tier — v0.14.x
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Starlark is the right sandbox for most extension logic: declarative,
|
||||
safe, fast to start, no deployment pipeline. But some workloads cannot
|
||||
run in Starlark:
|
||||
|
||||
- **ML inference** — Python + PyTorch/TF model loading, GPU access.
|
||||
- **Media transcoding** — ffmpeg, ImageMagick, Whisper.
|
||||
- **Language servers** — LSP processes for code-workspace.
|
||||
- **Local git** — clone, diff, merge operations on workspace directories.
|
||||
- **Custom runtimes** — anything needing system libraries, native code,
|
||||
or long-running processes.
|
||||
- **Personal local bridges** — a user's laptop exposing filesystem,
|
||||
terminal, clipboard, or local compute to the Armature instance.
|
||||
|
||||
Today, the `http` module lets Starlark call external APIs (`api.http`
|
||||
permission, domain allowlists, SSRF protection). This covers cloud
|
||||
services (OpenAI, S3, external webhooks) but not **co-located processes**
|
||||
that need access to Armature's data, events, and workspace filesystem,
|
||||
nor **personal processes** on a user's own machine.
|
||||
|
||||
The sidecar tier bridges this gap: out-of-process extensions that
|
||||
connect inward to the kernel, authenticate, register capabilities, and
|
||||
participate in the extension ecosystem as first-class citizens — at both
|
||||
the **instance level** (shared infrastructure) and the **user level**
|
||||
(personal local tools).
|
||||
|
||||
**If done wrong, the consequences are severe:**
|
||||
|
||||
- Wrong auth model → arbitrary processes access kernel APIs.
|
||||
- Wrong lifecycle → resource leaks, zombie processes, orphaned
|
||||
registrations.
|
||||
- Wrong API surface → every sidecar extension inherits the debt.
|
||||
- Wrong isolation → a runaway sidecar takes down the cluster.
|
||||
- Wrong discovery → fragile deployments that break on restart.
|
||||
- Wrong scoping → user sidecars see other users' data.
|
||||
|
||||
This is invisible infrastructure. Users never see it. But every
|
||||
extension that needs native code depends on the contract being right.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Kernel manages sidecar processes.** The kernel does not start, stop,
|
||||
or restart sidecars. That's the deployment layer's job (K8s, Docker
|
||||
Compose, systemd, or a human running a binary). The kernel discovers
|
||||
sidecars that connect to it.
|
||||
- **Sidecar marketplace.** Sidecar packages are installed like any other
|
||||
`.pkg` file. The sidecar binary ships separately (container image,
|
||||
standalone binary, pip package). The manifest declares the sidecar
|
||||
endpoint; the operator deploys it.
|
||||
- **Arbitrary bidirectional streaming.** Sidecars communicate via
|
||||
HTTP/JSON request-response and WebSocket events. No gRPC, no custom
|
||||
binary protocols. KISS.
|
||||
- **Hot code reload.** Sidecar updates require process restart. The
|
||||
kernel detects the reconnection and re-registers capabilities.
|
||||
- **Multi-tenant sidecar isolation.** Instance-level sidecars run at the
|
||||
instance level, not per-user. User-level isolation is handled by
|
||||
user sidecars (v0.14.5), which are scoped to the owning user's
|
||||
identity.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connect-Inward Model
|
||||
|
||||
The defining property: **sidecars connect TO the kernel, not the other
|
||||
way around.** The kernel never reaches outward to discover or contact
|
||||
a sidecar. This eliminates:
|
||||
|
||||
- K8s RBAC for the kernel to discover pods.
|
||||
- Service mesh configuration.
|
||||
- DNS-based service discovery.
|
||||
- Any notion of the kernel "managing" external processes.
|
||||
|
||||
A sidecar is just a process that knows the kernel's address and has
|
||||
credentials to authenticate. It could be a container in the same pod, a
|
||||
separate deployment, a systemd service on the same machine, or a process
|
||||
on a developer's laptop connected via tunnel.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Deployment Layer (K8s / Docker / systemd / manual) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ instance │ │ instance │ │ instance │ │
|
||||
│ │ sidecar: │ │ sidecar: │ │ sidecar: │ │
|
||||
│ │ ml-infer │ │ ffmpeg │ │ git-ops │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ connect inward │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Armature Kernel │ │
|
||||
│ │ ┌─────────────────────────────────────┐ │ │
|
||||
│ │ │ Sidecar Registry (PG) │ │ │
|
||||
│ │ │ ml-infer: instance, 3 caps │ │ │
|
||||
│ │ │ ffmpeg: instance, 2 caps │ │ │
|
||||
│ │ │ git-ops: instance, 4 caps │ │ │
|
||||
│ │ │ jeff-local: user:jeff, 3 caps │ │ │
|
||||
│ │ └─────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────▲─────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────────────┼──────────────────┘
|
||||
│ connect inward
|
||||
┌─────┴──────┐
|
||||
│ user │
|
||||
│ sidecar: │
|
||||
│ jeff-local │
|
||||
│ (laptop) │
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
### Two Scopes
|
||||
|
||||
**Instance sidecars** — shared infrastructure. Deployed by the operator.
|
||||
Any user's requests can invoke their capabilities. An ML inference
|
||||
sidecar, a transcoding service, a git operations worker.
|
||||
|
||||
**User sidecars** — personal processes. Run by an individual user on
|
||||
their own machine. Only the owning user's requests can invoke their
|
||||
capabilities. A local file bridge, a personal Ollama instance, a
|
||||
script that reacts to Armature events.
|
||||
|
||||
Both use the same connect-inward mechanism, same registration flow,
|
||||
same capability contract. The difference is the identity binding and
|
||||
access scoping.
|
||||
|
||||
### Two Authentication Modes
|
||||
|
||||
#### Mode A: Registration Token
|
||||
|
||||
For simple deployments (Docker Compose, single machine, dev/test).
|
||||
|
||||
1. Admin generates an instance sidecar token:
|
||||
`armature sidecar token create --package ml-inference`
|
||||
User generates a personal sidecar token (from settings UI or CLI):
|
||||
`armature sidecar token create --personal`
|
||||
2. Token is a signed JWT with claims: `{package_id, scope, user_id, exp}`.
|
||||
Instance tokens: `scope: "instance"`, `user_id: null`.
|
||||
User tokens: `scope: "user"`, `user_id: "<owner>"`.
|
||||
3. Sidecar starts with the token as env: `ARMATURE_TOKEN=ey...`
|
||||
4. Sidecar calls `POST /api/v1/sidecar/register`.
|
||||
5. Kernel validates JWT, creates registry entry with appropriate scope.
|
||||
|
||||
#### Mode B: mTLS
|
||||
|
||||
For production deployments with certificate infrastructure.
|
||||
|
||||
1. Certificate CN encodes identity:
|
||||
Instance: `sidecar:ml-inference`
|
||||
User: `sidecar-user:jeff:local-bridge`
|
||||
2. Kernel's `MTLSNativeProvider` parses the CN prefix and resolves to
|
||||
the appropriate sidecar identity.
|
||||
|
||||
Both modes produce the same internal identity: a `SidecarIdentity`
|
||||
struct with `{package_id, scope, user_id, node_id, registered_at}`.
|
||||
|
||||
### Sidecar Registry
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sidecar_registry (
|
||||
sidecar_id TEXT PRIMARY KEY,
|
||||
package_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'instance',
|
||||
user_id TEXT,
|
||||
endpoint TEXT NOT NULL,
|
||||
heartbeat TIMESTAMPTZ DEFAULT now(),
|
||||
capabilities JSONB DEFAULT '[]',
|
||||
stats JSONB DEFAULT '{}',
|
||||
registered_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sidecar_pkg ON sidecar_registry (package_id);
|
||||
CREATE INDEX idx_sidecar_user ON sidecar_registry (user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
```
|
||||
|
||||
`scope` is `'instance'` or `'user'`. `user_id` is NULL for instance
|
||||
sidecars, set for user sidecars.
|
||||
|
||||
**Lifecycle (mirrors cluster registry):**
|
||||
|
||||
- **Register:** `POST /api/v1/sidecar/register` with capability
|
||||
manifest. Kernel creates/updates registry row.
|
||||
- **Heartbeat:** `POST /api/v1/sidecar/heartbeat` every N seconds.
|
||||
- **Sweep:** Kernel's heartbeat loop sweeps stale sidecar entries
|
||||
alongside node entries. 3× heartbeat interval = stale.
|
||||
- **Self-eviction:** Heartbeat returns 404 → re-register or exit.
|
||||
- **Deregister:** `DELETE /api/v1/sidecar/deregister` on graceful
|
||||
shutdown.
|
||||
|
||||
### Capability Registration
|
||||
|
||||
On registration, the sidecar declares capabilities:
|
||||
|
||||
```json
|
||||
POST /api/v1/sidecar/register
|
||||
{
|
||||
"package_id": "ml-inference",
|
||||
"endpoint": "http://ml-inference:8080",
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "embed",
|
||||
"description": "Generate text embeddings",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"model": { "type": "string", "default": "all-MiniLM-L6-v2" }
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
{
|
||||
"name": "classify",
|
||||
"description": "Zero-shot text classification",
|
||||
"input_schema": { ... },
|
||||
"timeout_seconds": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Sidecar SDK (Starlark)
|
||||
|
||||
New `sidecar` Starlark module. Permission: `sidecar.call`.
|
||||
|
||||
```python
|
||||
# Call an instance sidecar capability
|
||||
result = sidecar.call("ml-inference", "embed", {"text": "Hello"})
|
||||
|
||||
# Check availability
|
||||
available = sidecar.available("ml-inference")
|
||||
|
||||
# List capabilities
|
||||
caps = sidecar.capabilities("ml-inference")
|
||||
```
|
||||
|
||||
User sidecar calls are routed automatically. When a request originates
|
||||
from user Jeff, `sidecar.call("user-bridge", "fs.search", {...})` hits
|
||||
Jeff's personal sidecar. The same call from user Alice would hit Alice's
|
||||
personal `user-bridge` sidecar (or fail if she doesn't have one). The
|
||||
Starlark caller doesn't specify the user — the kernel resolves it from
|
||||
the request context.
|
||||
|
||||
### Sidecar SDK (Frontend)
|
||||
|
||||
```js
|
||||
const result = await sw.sidecar.call('ml-inference', 'embed', {
|
||||
text: 'Hello, world'
|
||||
});
|
||||
const available = await sw.sidecar.available('user-bridge');
|
||||
```
|
||||
|
||||
Frontend calls go through the kernel API. The kernel proxies to the
|
||||
appropriate sidecar based on scope + user identity.
|
||||
|
||||
### Sidecar API Contract (Inbound to Kernel)
|
||||
|
||||
| Endpoint | Purpose | Auth |
|
||||
|----------|---------|------|
|
||||
| `POST /api/v1/sidecar/register` | Register + declare capabilities | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/heartbeat` | Heartbeat + stats | Token or mTLS |
|
||||
| `DELETE /api/v1/sidecar/deregister` | Graceful shutdown | Token or mTLS |
|
||||
| `GET /api/v1/ext/{pkg}/*` | Read extension data (scoped) | Token or mTLS |
|
||||
| `POST /api/v1/ext/{pkg}/*` | Write extension data (scoped) | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/emit` | Emit events | Token or mTLS |
|
||||
| `GET /api/v1/sidecar/subscribe` | WebSocket event subscription | Token or mTLS |
|
||||
| `POST /api/v1/sidecar/lib/{pkg}/{fn}` | Cross-package function call | Token or mTLS |
|
||||
|
||||
**Scoping rules:**
|
||||
|
||||
- Instance sidecars: can access `/api/v1/ext/{own_package}/*` only.
|
||||
- User sidecars: can access `/api/v1/ext/{own_package}/*` scoped to
|
||||
the owning user's data within that package. The sidecar sees the
|
||||
same data the user would see through the UI.
|
||||
- Event emission scoped to the sidecar's package prefix.
|
||||
|
||||
### Sidecar API Contract (Outbound from Kernel)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST {sidecar_endpoint}/api/v1/exec/{capability}` | Execute a capability |
|
||||
| `GET {sidecar_endpoint}/api/v1/health` | Health check |
|
||||
|
||||
The `/exec/{capability}` endpoint receives:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": { ... },
|
||||
"context": {
|
||||
"user_id": "...",
|
||||
"package_id": "...",
|
||||
"request_id": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": { ... },
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
What the kernel enforces at the proxy layer:
|
||||
|
||||
- **Timeout:** Per-capability, from capability declaration.
|
||||
- **Request rate:** Per-sidecar, configurable in admin.
|
||||
- **Response size:** Default 10MB, configurable per-sidecar.
|
||||
- **Concurrent calls:** Default 10, configurable per-sidecar.
|
||||
|
||||
User sidecars have separate (typically lower) defaults:
|
||||
|
||||
- **User sidecar rate limit:** Default 30/min (vs 100/min instance).
|
||||
- **User sidecar concurrency:** Default 3 (vs 10 instance).
|
||||
- **User sidecar response size:** Default 5MB (vs 10MB instance).
|
||||
|
||||
Admin can adjust user sidecar defaults globally. Individual users
|
||||
cannot override admin limits.
|
||||
|
||||
### Manifest Integration
|
||||
|
||||
Instance sidecar manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ml-inference",
|
||||
"type": "full",
|
||||
"tier": "sidecar",
|
||||
"version": "1.0.0",
|
||||
|
||||
"sidecar": {
|
||||
"image": "gobha/armature-ml-inference:latest",
|
||||
"required": true,
|
||||
"health_endpoint": "/api/v1/health",
|
||||
"env_hints": {
|
||||
"ARMATURE_URL": "Kernel URL (auto-populated)",
|
||||
"ARMATURE_TOKEN": "Registration token",
|
||||
"MODEL_PATH": "Path to model files"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
User sidecar manifest (installed as a regular package, sidecar runs
|
||||
on user's machine):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "user-bridge",
|
||||
"type": "full",
|
||||
"tier": "sidecar",
|
||||
"version": "1.0.0",
|
||||
|
||||
"sidecar": {
|
||||
"scope": "user",
|
||||
"required": false,
|
||||
"download_url": "https://github.com/armature/user-bridge/releases",
|
||||
"env_hints": {
|
||||
"ARMATURE_URL": "Your Armature instance URL",
|
||||
"ARMATURE_TOKEN": "Generate from Settings → Sidecars"
|
||||
}
|
||||
},
|
||||
|
||||
"surfaces": ["/user-bridge/settings"]
|
||||
}
|
||||
```
|
||||
|
||||
`sidecar.scope: "user"` tells the kernel this is a user sidecar
|
||||
package. The package installs normally (admin or self-install depending
|
||||
on permissions), but the sidecar binary runs on the user's machine.
|
||||
|
||||
### Tool Meta-Tool Integration
|
||||
|
||||
When `llm-bridge` assembles tools for a completion:
|
||||
|
||||
1. Instance-level actions from `sw.actions.list()` → available to all.
|
||||
2. Instance sidecar capabilities → available to all.
|
||||
3. **Requesting user's personal sidecar capabilities** → available to
|
||||
that user only.
|
||||
|
||||
This means different users get different tool sets. Jeff has
|
||||
`user-bridge` running with `fs.search`, `terminal.exec`, and
|
||||
`clipboard.get`. Alice doesn't. When Jeff asks "Hey Max, find the auth
|
||||
file on my desktop," Max has `user-bridge.fs.search` in his tool set.
|
||||
When Alice asks the same, that tool doesn't exist — Max tells her he
|
||||
can't access her local files.
|
||||
|
||||
The same AI persona, different capabilities per user, zero configuration.
|
||||
|
||||
### Admin UI
|
||||
|
||||
**Instance sidecar management:**
|
||||
- Sidecar health status per registered instance sidecar.
|
||||
- Capabilities list with call counts, error rates, latency.
|
||||
- Token management: generate, revoke, list.
|
||||
- Per-sidecar rate limit and concurrency configuration.
|
||||
|
||||
**User sidecar admin controls:**
|
||||
- Permission: `sidecar.connect_personal` — admin grants to specific
|
||||
users or groups.
|
||||
- Capability allowlist for user sidecars — admin restricts which
|
||||
capabilities user sidecars can declare.
|
||||
- Global user sidecar resource limits (rate, concurrency, response
|
||||
size).
|
||||
- Monitoring: list of all connected user sidecars with owner, endpoint,
|
||||
capabilities, last heartbeat.
|
||||
- Kill switch: admin can revoke any user sidecar token or sweep a
|
||||
specific user's sidecars.
|
||||
|
||||
**User settings (for users with `sidecar.connect_personal`):**
|
||||
- Generate personal sidecar token.
|
||||
- List personal connected sidecars with health status.
|
||||
- Revoke own tokens.
|
||||
- Download link / instructions for user sidecar binaries (from package
|
||||
manifest `download_url`).
|
||||
|
||||
### Monitoring
|
||||
|
||||
Kernel-side metrics:
|
||||
|
||||
- `sidecar_call_total{package, capability, scope, status}` — counter.
|
||||
- `sidecar_call_duration_seconds{package, capability, scope}` — histogram.
|
||||
- `sidecar_heartbeat_age_seconds{package, scope}` — gauge.
|
||||
- `sidecar_active{package, scope}` — gauge.
|
||||
- `sidecar_user_count` — gauge (number of connected user sidecars).
|
||||
|
||||
---
|
||||
|
||||
## User Sidecar Use Cases
|
||||
|
||||
### Local Bridge (Desktop Commander Pattern)
|
||||
|
||||
A thin agent on the user's laptop that exposes local capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "fs.search",
|
||||
"description": "Search local filesystem by filename or content",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"query": { "type": "string" },
|
||||
"path": { "type": "string", "default": "~" },
|
||||
"content_search": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["query"]
|
||||
},
|
||||
"timeout_seconds": 15
|
||||
},
|
||||
{
|
||||
"name": "fs.read",
|
||||
"description": "Read a local file",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"max_bytes": { "type": "integer", "default": 1048576 }
|
||||
},
|
||||
"required": ["path"]
|
||||
},
|
||||
"timeout_seconds": 10
|
||||
},
|
||||
{
|
||||
"name": "terminal.exec",
|
||||
"description": "Execute a shell command",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"command": { "type": "string" },
|
||||
"cwd": { "type": "string", "default": "~" },
|
||||
"timeout": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["command"]
|
||||
},
|
||||
"timeout_seconds": 60
|
||||
},
|
||||
{
|
||||
"name": "clipboard.get",
|
||||
"description": "Read clipboard contents",
|
||||
"input_schema": {},
|
||||
"timeout_seconds": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tool meta-tool flow:**
|
||||
|
||||
```
|
||||
User: "Hey Max, find the database migration script I was
|
||||
working on yesterday and add it to my notes"
|
||||
|
||||
llm-bridge assembles (for this user):
|
||||
Layer 5 tools: [...instance tools..., user-bridge.fs.search,
|
||||
user-bridge.fs.read, notes.create, ...]
|
||||
|
||||
LLM:
|
||||
1. Tool: user-bridge.fs.search({query: "migration", path: "~/code"})
|
||||
2. Result: [{path: "~/code/armature/migrations/015_sidecar.sql", ...}]
|
||||
3. Tool: user-bridge.fs.read({path: "~/code/armature/migrations/015_sidecar.sql"})
|
||||
4. Result: {content: "CREATE TABLE IF NOT EXISTS sidecar_registry..."}
|
||||
5. Tool: notes.create({title: "Migration 015 - Sidecar Registry",
|
||||
body: "```sql\nCREATE TABLE..."})
|
||||
6. Text: "Found your sidecar migration script and saved it to Notes."
|
||||
```
|
||||
|
||||
Three extensions orchestrated: user's local filesystem, instance-level
|
||||
notes, all through the same tool meta-tool pattern.
|
||||
|
||||
### Personal Compute (Local Ollama)
|
||||
|
||||
User runs Ollama on their workstation with a GPU:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "complete",
|
||||
"description": "LLM completion via local Ollama",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"prompt": { "type": "string" },
|
||||
"model": { "type": "string", "default": "llama3" }
|
||||
},
|
||||
"required": ["prompt"]
|
||||
},
|
||||
"timeout_seconds": 120
|
||||
},
|
||||
{
|
||||
"name": "embed",
|
||||
"description": "Local embedding via Ollama",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"model": { "type": "string", "default": "nomic-embed-text" }
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"timeout_seconds": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`llm-bridge` can be configured (per-user setting or persona preference)
|
||||
to route completions through the user's local Ollama instead of the
|
||||
instance-level provider. BYOK taken to the extreme — bring your own
|
||||
hardware.
|
||||
|
||||
### Personal Automation
|
||||
|
||||
A script that reacts to Armature events:
|
||||
|
||||
```python
|
||||
# Personal deploy watcher — connects to Armature, subscribes to
|
||||
# events in the #deploys conversation, runs kubectl locally
|
||||
|
||||
ws = connect(f'{KERNEL_URL}/api/v1/sidecar/subscribe',
|
||||
token=TOKEN)
|
||||
|
||||
for event in ws:
|
||||
if event['type'] == 'chat.message' and 'deploy' in event['content']:
|
||||
result = subprocess.run(['kubectl', 'get', 'pods', '-n', 'prod'],
|
||||
capture_output=True, text=True)
|
||||
# Post result back to chat
|
||||
requests.post(f'{KERNEL_URL}/api/v1/sidecar/lib/chat-core/send',
|
||||
json={'conversation_id': event['conversation_id'],
|
||||
'content': f'```\n{result.stdout}\n```'},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
```
|
||||
|
||||
Personal workflow glue. No instance-level deployment required.
|
||||
|
||||
---
|
||||
|
||||
## RBAC for User Sidecars
|
||||
|
||||
Three-layer permission model:
|
||||
|
||||
### Layer 1: Admin Controls WHO
|
||||
|
||||
New permission: `sidecar.connect_personal`.
|
||||
|
||||
Admin grants to specific users, groups, or roles. Most users don't
|
||||
need it. Developers, power users, AI-heavy workflows — they get the
|
||||
permission.
|
||||
|
||||
Default: not granted. Opt-in only.
|
||||
|
||||
### Layer 2: Admin Controls WHAT
|
||||
|
||||
**Capability allowlist for user sidecars.**
|
||||
|
||||
Admin setting: `SIDECAR_USER_ALLOWED_CAPABILITIES`
|
||||
|
||||
Default: `["fs.search", "fs.read", "clipboard.get"]` — safe read-only
|
||||
operations.
|
||||
|
||||
To enable terminal access: admin adds `"terminal.exec"` to the
|
||||
allowlist. This is a deliberate escalation requiring admin action.
|
||||
|
||||
A user sidecar that attempts to register a capability not on the
|
||||
allowlist gets a clear error: "Capability 'terminal.exec' not permitted
|
||||
for user sidecars. Contact your administrator."
|
||||
|
||||
### Layer 3: User Controls VISIBILITY
|
||||
|
||||
User sidecar capabilities are **private by default** — only the owning
|
||||
user's requests invoke them.
|
||||
|
||||
Optional: user can share a specific capability with their team via the
|
||||
settings UI. Shared capabilities appear in team members' tool sets
|
||||
(with attribution: "via Jeff's local bridge").
|
||||
|
||||
User sidecars are never instance-wide. That's what instance sidecars
|
||||
are for.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Instance Sidecars
|
||||
|
||||
1. **Compromised binary** → can read/write own package data, call
|
||||
exported functions. Mitigation: package-scoped access, audit logging,
|
||||
token revocation.
|
||||
2. **Token theft** → impersonate sidecar. Mitigation: package-scoped
|
||||
tokens, mTLS eliminates theft, revocable.
|
||||
3. **SSRF via sidecar.call** → Mitigation: endpoints validated at
|
||||
registration, no runtime changes.
|
||||
4. **Resource exhaustion** → Mitigation: per-sidecar concurrency and
|
||||
rate limits, timeout enforcement, response size limits.
|
||||
5. **Privilege escalation via lib.require** → Mitigation: calls
|
||||
attributed to sidecar identity, target permission checks apply.
|
||||
|
||||
### User Sidecars (Additional Concerns)
|
||||
|
||||
6. **User deploys malicious sidecar** → registers capabilities that
|
||||
exfiltrate data when called. Mitigation: capability allowlist (admin
|
||||
controls what capabilities can be declared), private-by-default
|
||||
(only the user's own requests trigger it — they're exfiltrating
|
||||
from themselves).
|
||||
|
||||
7. **User shares malicious capability with team** → team members
|
||||
invoke it, sidecar captures their request data. Mitigation: shared
|
||||
capabilities are clearly attributed ("via Jeff's local bridge").
|
||||
Admin can disable capability sharing entirely. Shared capabilities
|
||||
run with the CALLING user's permissions, not the sidecar owner's.
|
||||
|
||||
8. **User sidecar as pivot** → user's laptop compromised, attacker uses
|
||||
connected sidecar to access Armature data. Mitigation: user sidecar
|
||||
can only access data the user could already access through the UI.
|
||||
No privilege escalation. User can revoke own tokens. Admin kill
|
||||
switch.
|
||||
|
||||
9. **Stale user sidecars** → user's laptop goes offline, sidecar
|
||||
becomes stale. Mitigation: same sweep mechanism as instance
|
||||
sidecars. 3× heartbeat interval = swept. User reconnects when
|
||||
laptop comes back online.
|
||||
|
||||
### Principle of Least Privilege
|
||||
|
||||
- Instance sidecar: access own package data only, middleware-enforced.
|
||||
- User sidecar: access own package data scoped to owning user only.
|
||||
- Event emission scoped to package prefix.
|
||||
- Capability allowlist for user sidecars (admin-controlled).
|
||||
- Shared capabilities run with caller's permissions.
|
||||
|
||||
---
|
||||
|
||||
## Version Plan
|
||||
|
||||
### v0.14.0 — Sidecar Registry + Auth
|
||||
|
||||
**Goal:** Sidecars can register, authenticate, and heartbeat.
|
||||
|
||||
- Sidecar registry table with `scope` and `user_id` columns.
|
||||
- Registration endpoint with JWT token validation.
|
||||
- Heartbeat + sweep integration.
|
||||
- Token generation (instance tokens via admin, user tokens via settings).
|
||||
- `SIDECAR_AUTH_MODE` config: `token` or `mtls`.
|
||||
- mTLS auth: `sidecar:` and `sidecar-user:` CN prefixes.
|
||||
- Admin UI: sidecar health on packages page.
|
||||
|
||||
**Instance sidecars only in this version.** User sidecar plumbing
|
||||
exists in the schema but user token generation and RBAC gating land
|
||||
in v0.14.5.
|
||||
|
||||
### v0.14.1 — Capability Registration + Execution
|
||||
|
||||
**Goal:** Sidecars declare capabilities. Starlark extensions call them.
|
||||
|
||||
- Capability manifest in registration payload.
|
||||
- `sidecar` Starlark module: `call()`, `available()`, `capabilities()`.
|
||||
- Kernel proxy: `sidecar.call()` → `POST {endpoint}/exec/{cap}`.
|
||||
- Input validation against declared schema.
|
||||
- Timeout, response size, concurrent call limits.
|
||||
- Error propagation.
|
||||
- `sidecar.call` permission.
|
||||
- Audit logging.
|
||||
|
||||
### v0.14.2 — Kernel API Access + Event Bus
|
||||
|
||||
**Goal:** Sidecars read/write their own data and participate in events.
|
||||
|
||||
- Sidecar middleware: authenticate, scope to own package routes.
|
||||
- `/api/v1/sidecar/lib/{pkg}/{fn}` — cross-package function calls.
|
||||
- `/api/v1/sidecar/emit` — event emission (package-scoped).
|
||||
- `/api/v1/sidecar/subscribe` — WebSocket event subscription.
|
||||
- Frontend `sw.sidecar` SDK module.
|
||||
|
||||
### v0.14.3 — Manifest Integration + Admin Polish
|
||||
|
||||
**Goal:** Sidecar packages are first-class in install/admin flow.
|
||||
|
||||
- `tier: "sidecar"` manifest support.
|
||||
- `sidecar` manifest field (image, required, scope, env_hints,
|
||||
download_url).
|
||||
- Admin UI: deployment instructions from manifest.
|
||||
- Admin UI: capability list with metrics.
|
||||
- Admin UI: per-sidecar rate limit configuration.
|
||||
- Token management UI.
|
||||
- Monitoring metrics.
|
||||
|
||||
### v0.14.4 — Reference Sidecar + Instance Quality Gate
|
||||
|
||||
**Goal:** Ship one real instance sidecar. Prove the contract.
|
||||
|
||||
**`armature-embed`** — minimal Python sidecar running
|
||||
sentence-transformers for local embedding generation. One capability:
|
||||
`embed(text) → float[]`.
|
||||
|
||||
`vector-store` calls `sidecar.call('armature-embed', 'embed', {...})`
|
||||
for local embedding without an external API. Completes the local-first
|
||||
RAG story.
|
||||
|
||||
**Instance sidecar quality gate:**
|
||||
|
||||
- Registration, heartbeat, sweep lifecycle tested.
|
||||
- Token and mTLS auth both functional.
|
||||
- Capability execution with limits enforced.
|
||||
- Package-scoped API access enforced.
|
||||
- Event emission/subscription functional.
|
||||
- `sw.sidecar` frontend module functional.
|
||||
- Reference sidecar runs in Docker and K8s.
|
||||
- Admin UI complete.
|
||||
- Monitoring metrics in Grafana.
|
||||
|
||||
### v0.14.5 — User Sidecars
|
||||
|
||||
**Goal:** Users connect personal processes to Armature.
|
||||
|
||||
- `sidecar.connect_personal` permission (admin-granted).
|
||||
- User token generation in Settings → Sidecars.
|
||||
- Capability allowlist for user sidecars (admin setting:
|
||||
`SIDECAR_USER_ALLOWED_CAPABILITIES`).
|
||||
- User sidecar registration with `scope: "user"`, `user_id` binding.
|
||||
- Execution routing: `sidecar.call()` resolves user sidecars based
|
||||
on request context (calling user gets their own sidecar).
|
||||
- Capability sharing: user can share specific capabilities with team.
|
||||
Shared caps attributed ("via Jeff's local bridge").
|
||||
- Separate resource limits for user sidecars (lower defaults).
|
||||
- User settings UI: connected sidecars, token management, sidecar
|
||||
instructions from package manifests.
|
||||
- Admin UI: user sidecar monitoring, kill switch.
|
||||
- Tool meta-tool integration: `llm-bridge` includes user's personal
|
||||
sidecar capabilities in tool set assembly.
|
||||
|
||||
**Reference user sidecar: `user-bridge`** — thin agent binary with
|
||||
`fs.search`, `fs.read`, `clipboard.get` capabilities. Published as a
|
||||
package with `sidecar.scope: "user"`. Binary as a GitHub release.
|
||||
|
||||
**User sidecar quality gate:**
|
||||
|
||||
- RBAC: user without `sidecar.connect_personal` cannot register.
|
||||
- Capability allowlist enforced.
|
||||
- User A cannot invoke user B's sidecar.
|
||||
- Shared capabilities run with caller's permissions.
|
||||
- Admin can revoke any user sidecar.
|
||||
- Tool meta-tool assembles per-user tool sets correctly.
|
||||
- `user-bridge` reference sidecar runs on macOS, Linux, Windows.
|
||||
|
||||
---
|
||||
|
||||
## Schema Summary
|
||||
|
||||
### New tables
|
||||
|
||||
- `sidecar_registry` — sidecar_id, package_id, scope, user_id,
|
||||
endpoint, heartbeat, capabilities (JSON), stats (JSON),
|
||||
registered_at. (v0.14.0)
|
||||
- `sidecar_tokens` — id, package_id, scope, user_id, token_hash,
|
||||
created_by, expires_at, revoked_at. (v0.14.0)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `SIDECAR_AUTH_MODE` | `token` | `token` or `mtls` |
|
||||
| `SIDECAR_HEARTBEAT_INTERVAL` | `10s` | Expected heartbeat frequency |
|
||||
| `SIDECAR_STALE_THRESHOLD` | `30s` | 3× heartbeat = stale |
|
||||
| `SIDECAR_MAX_RESPONSE_SIZE` | `10485760` (10MB) | Instance default |
|
||||
| `SIDECAR_DEFAULT_CONCURRENCY` | `10` | Instance default |
|
||||
| `SIDECAR_DEFAULT_RATE_LIMIT` | `100/min` | Instance default |
|
||||
| `SIDECAR_USER_MAX_RESPONSE_SIZE` | `5242880` (5MB) | User default |
|
||||
| `SIDECAR_USER_CONCURRENCY` | `3` | User default |
|
||||
| `SIDECAR_USER_RATE_LIMIT` | `30/min` | User default |
|
||||
| `SIDECAR_USER_ALLOWED_CAPABILITIES` | `["fs.search","fs.read","clipboard.get"]` | Admin-controlled allowlist |
|
||||
|
||||
---
|
||||
|
||||
## Sidecar Author Contract
|
||||
|
||||
### Minimum Viable Sidecar (Python)
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import requests, os, threading, time
|
||||
|
||||
app = Flask(__name__)
|
||||
KERNEL = os.environ['ARMATURE_URL']
|
||||
TOKEN = os.environ['ARMATURE_TOKEN']
|
||||
|
||||
def register():
|
||||
requests.post(f'{KERNEL}/api/v1/sidecar/register',
|
||||
json={
|
||||
'package_id': 'my-sidecar',
|
||||
'endpoint': f'http://localhost:8080',
|
||||
'capabilities': [{
|
||||
'name': 'echo',
|
||||
'description': 'Echo input back',
|
||||
'input_schema': {
|
||||
'type': 'object',
|
||||
'properties': {'text': {'type': 'string'}},
|
||||
'required': ['text']
|
||||
},
|
||||
'timeout_seconds': 10
|
||||
}]
|
||||
},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
|
||||
def heartbeat():
|
||||
while True:
|
||||
time.sleep(10)
|
||||
r = requests.post(f'{KERNEL}/api/v1/sidecar/heartbeat',
|
||||
json={'stats': {}},
|
||||
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||
if r.status_code == 404:
|
||||
register()
|
||||
|
||||
@app.route('/api/v1/exec/echo', methods=['POST'])
|
||||
def exec_echo():
|
||||
return jsonify({
|
||||
'output': {'text': request.json['input']['text']},
|
||||
'error': None
|
||||
})
|
||||
|
||||
@app.route('/api/v1/health')
|
||||
def health():
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
register()
|
||||
threading.Thread(target=heartbeat, daemon=True).start()
|
||||
app.run(port=8080)
|
||||
```
|
||||
|
||||
Four things: register, heartbeat, `/exec/`, `/health`. That's the
|
||||
contract.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Connect-inward | Eliminates K8s RBAC, service mesh, DNS discovery. Works on any deployment target. |
|
||||
| Two auth modes | Token for simple. mTLS for production. Same internal identity. |
|
||||
| Cluster registry pattern | Proven heartbeat/sweep. No new coordination mechanism. |
|
||||
| HTTP/JSON, no gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||
| Kernel proxies all calls | Auth, rate limits, audit, timeouts at single control point. |
|
||||
| Package-scoped access | Compromised sidecar can't read other packages. |
|
||||
| User sidecars as v0.14.5 | Prove instance contract first (v0.14.0–v0.14.4), then extend to users. Same mechanism, new scope. |
|
||||
| Three-layer user RBAC | Admin controls who (permission), what (capability allowlist), user controls visibility (private/shared). Defense in depth. |
|
||||
| Capability allowlist for user sidecars | Admin decides what user processes can expose. `terminal.exec` is a deliberate escalation. |
|
||||
| Private-by-default user capabilities | User sidecar can't affect other users unless explicitly shared. |
|
||||
| Shared caps run with caller's perms | Prevents privilege escalation through shared capabilities. |
|
||||
| Reference instance sidecar: embedding | Simpler than LLM. Proves contract. Completes local RAG story. |
|
||||
| Reference user sidecar: file bridge | Most compelling user sidecar demo. "Find a file on my laptop and save it to notes" is visceral. |
|
||||
| Lower resource limits for user sidecars | User sidecars are less trusted (running on personal devices). Lower defaults, admin can't be overridden by users. |
|
||||
| Separate from polish (v0.15.x) | Sidecar is security-critical infrastructure. Polish is quality-of-life. Don't mix. |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Streaming responses.** LLM sidecar needs token streaming. SSE or
|
||||
chunked transfer. Deferred — batch covers embedding, classification,
|
||||
transcoding.
|
||||
- **SDK libraries.** Thin wrappers in Python, Go, Node, Rust for
|
||||
registration + heartbeat + parsing. Convenience, not requirement.
|
||||
- **Auto-scaling.** Multiple instances of same sidecar, kernel
|
||||
load-balances. No schema change needed — registry supports multiple
|
||||
rows per package.
|
||||
- **GPU detection.** Kernel capability for manifest negotiation.
|
||||
- **Sidecar-to-sidecar.** Direct calls for performance pipelines.
|
||||
Currently goes through kernel.
|
||||
- **User sidecar desktop app.** Electron/Tauri tray app that wraps the
|
||||
user-bridge agent with a GUI for capability management and connection
|
||||
status. Post-1.0.
|
||||
157
docs/ROADMAP-v010x-v100.md
Normal file
157
docs/ROADMAP-v010x-v100.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# ROADMAP — v0.10.x → v1.0.0 (Final)
|
||||
|
||||
## Full Roadmap
|
||||
|
||||
| Series | Title | Versions | Design Doc |
|
||||
|--------|-------|----------|------------|
|
||||
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||
| **v0.14.x** | **Sidecar Tier** | 6 | `DESIGN-sidecar-v014x.md` |
|
||||
| **v0.15.x** | **Polish + Stability** | 3 | — |
|
||||
| **v1.0.0** | **Stable Release** | — | — |
|
||||
|
||||
**Total: ~53 versions from v0.10.0 to v1.0.0**
|
||||
|
||||
---
|
||||
|
||||
## v0.10.x — Panels + Composable Layout
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||
| v0.10.1 | FloatingPanel Primitive |
|
||||
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||
| v0.10.3 | Panel Communication Patterns |
|
||||
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||
|
||||
---
|
||||
|
||||
## v0.11.x — Notes Reference Extension
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.11.0 | UI/UX Foundation |
|
||||
| v0.11.1 | Deep Folders + Navigation |
|
||||
| v0.11.2 | Wikilinks + Backlinks |
|
||||
| v0.11.3 | Live Preview + Rich Editing |
|
||||
| v0.11.4 | Note Sharing + Permissions |
|
||||
| v0.11.5 | Graph + Outline Hardening |
|
||||
| v0.11.6 | Quick Switcher + Commands |
|
||||
| v0.11.7 | Daily Notes + Templates |
|
||||
| v0.11.8 | Transclusion + Embeds |
|
||||
| v0.11.9 | Composability: Slots + Actions |
|
||||
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.12.x — Chat Reference Extension
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.12.0 | UI/UX Foundation |
|
||||
| v0.12.1 | Conversation Folders + Attributes |
|
||||
| v0.12.2 | Reactions + Threads + Pins |
|
||||
| v0.12.3 | Rich Compose + Attachments |
|
||||
| v0.12.4 | @Mentions + Notifications |
|
||||
| v0.12.5 | Link Previews + Message Formatting |
|
||||
| v0.12.6 | Conversation Themes + Personality |
|
||||
| v0.12.7 | Composability: Slots + Actions |
|
||||
| v0.12.8 | Panels + Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.13.x — Reference Libraries + Extensions
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.13.0 | `vector-store` Library |
|
||||
| v0.13.1 | `llm-bridge` Core Library |
|
||||
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona + Tool Meta-Tool |
|
||||
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||
| v0.13.4 | `file-share` Extension |
|
||||
| v0.13.5 | `code-workspace` Extension |
|
||||
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||
| v0.13.8 | Integration Quality Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.14.x — Sidecar Tier
|
||||
|
||||
Connect-inward model. Instance sidecars (shared infrastructure) and
|
||||
user sidecars (personal local tools).
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.14.0 | Sidecar Registry + Auth |
|
||||
| v0.14.1 | Capability Registration + Execution |
|
||||
| v0.14.2 | Kernel API Access + Event Bus |
|
||||
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||
|
||||
---
|
||||
|
||||
## v0.15.x — Polish + Stability
|
||||
|
||||
| Version | Title |
|
||||
|---------|-------|
|
||||
| v0.15.0 | Native Dialog Audit |
|
||||
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 Gate Criteria
|
||||
|
||||
**Reference extensions:**
|
||||
- Notes shipped (all v0.11.x) with UI quality review
|
||||
- Chat shipped (all v0.12.x) with UI quality review
|
||||
- At least 3 reference extensions beyond notes/chat
|
||||
|
||||
**Composability:**
|
||||
- `llm-bridge` extends both notes and chat through slots/actions
|
||||
- Tool meta-tool: AI uses 3+ extension actions in a single turn
|
||||
- At least 2 panels consumed cross-package
|
||||
- At least 2 slot contributions per host surface
|
||||
|
||||
**Sidecar:**
|
||||
- Instance sidecar (`armature-embed`) deployed and functional
|
||||
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||
- Token + mTLS auth both tested
|
||||
- User sidecar RBAC enforced (permission, capability allowlist, scoping)
|
||||
- Tool meta-tool includes user sidecar capabilities per-user
|
||||
|
||||
**Infrastructure:**
|
||||
- Admin safety rails validated
|
||||
- Multi-persona context archetypes demonstrated
|
||||
- Sharing functional (notes + conversations)
|
||||
- Headless E2E green on PG + SQLite
|
||||
- All kernel Starlark modules documented
|
||||
- All API routes covered by OpenAPI spec
|
||||
- `armature migrate` CLI functional
|
||||
- Upgrade path tested v0.8.0 → v1.0.0
|
||||
- Single-binary + Docker + K8s deployment paths documented
|
||||
- Monitoring dashboard with kernel + sidecar metrics
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions Log
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Panels as kernel primitive | Z-index, drag, layout are kernel concerns |
|
||||
| UI/UX-first for reference extensions | Every feature builds on visual foundation |
|
||||
| Notes → Chat → Libraries → Sidecar | Each builds on proven patterns from previous |
|
||||
| Chat human-to-human first | AI is llm-bridge's job |
|
||||
| Folder attributes as extension bridge | Zero coupling between chat and llm-bridge |
|
||||
| Action registry as tool registry | Installed extensions = AI capabilities |
|
||||
| 6-layer prompt architecture | Admin safety not overridable |
|
||||
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery |
|
||||
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract |
|
||||
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism |
|
||||
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility |
|
||||
| Separate polish (v0.15.x) | Security-critical infra and quality-of-life shouldn't share focus |
|
||||
@@ -19,7 +19,6 @@
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "public",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -48,7 +47,6 @@
|
||||
"ordinal": 1,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -71,7 +69,6 @@
|
||||
"ordinal": 2,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"sla_seconds": 3600,
|
||||
"form_template": {
|
||||
@@ -91,7 +88,6 @@
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -108,9 +104,8 @@
|
||||
{
|
||||
"name": "verify",
|
||||
"ordinal": 4,
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -37,9 +36,8 @@
|
||||
{
|
||||
"name": "review",
|
||||
"ordinal": 1,
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"stage_config": {
|
||||
"validation": {
|
||||
@@ -53,7 +51,6 @@
|
||||
"ordinal": 2,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -76,7 +73,6 @@
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -69,16 +68,14 @@
|
||||
"ordinal": 1,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "employee-onboarding:on_provision"
|
||||
},
|
||||
{
|
||||
"name": "manager-signoff",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"stage_config": {
|
||||
"validation": {
|
||||
@@ -93,7 +90,6 @@
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -113,7 +109,6 @@
|
||||
"ordinal": 4,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "employee-onboarding:on_welcome"
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
name: 'Team Intake',
|
||||
ordinal: 0,
|
||||
history_mode: 'full',
|
||||
stage_mode: 'chat_only'
|
||||
stage_mode: 'form'
|
||||
});
|
||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||
T.assert(d.name === 'Team Intake', 'name mismatch');
|
||||
@@ -94,7 +94,7 @@
|
||||
name: 'Team Review',
|
||||
ordinal: 1,
|
||||
history_mode: 'summary',
|
||||
stage_mode: 'review'
|
||||
stage_mode: 'form'
|
||||
});
|
||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||
stageIds.push(d.id);
|
||||
@@ -111,7 +111,7 @@
|
||||
name: 'Team Intake (updated)',
|
||||
ordinal: 0,
|
||||
history_mode: 'full',
|
||||
stage_mode: 'form_chat',
|
||||
stage_mode: 'form',
|
||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
|
||||
});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
var fwfSlug = testTag + '-form-wf';
|
||||
var fChannelId = null;
|
||||
|
||||
await T.test('crud', 'workflows', 'form: create form_only workflow', async function () {
|
||||
await T.test('crud', 'workflows', 'form: create form workflow', async function () {
|
||||
var wf = await T.apiPost('/workflows', {
|
||||
name: testTag + ' Form WF',
|
||||
slug: fwfSlug,
|
||||
@@ -422,7 +422,7 @@
|
||||
name: 'Contact Info',
|
||||
ordinal: 0,
|
||||
history_mode: 'full',
|
||||
stage_mode: 'form_only',
|
||||
stage_mode: 'form',
|
||||
form_template: {
|
||||
fields: [
|
||||
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
|
||||
@@ -436,7 +436,7 @@
|
||||
});
|
||||
|
||||
await T.apiPost('/workflows/' + fwfId + '/stages', {
|
||||
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'chat_only'
|
||||
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form'
|
||||
});
|
||||
|
||||
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
|
||||
@@ -456,7 +456,7 @@
|
||||
var d = await T.sessionGet('/w/' + fChannelId + '/form');
|
||||
T.assert(d._status === 200, 'expected 200, got ' + d._status);
|
||||
T.assertHasKey(d, 'stage_mode', 'form response');
|
||||
T.assert(d.stage_mode === 'form_only', 'expected form_only, got ' + d.stage_mode);
|
||||
T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode);
|
||||
T.assertHasKey(d, 'form_template', 'form response');
|
||||
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
|
||||
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
|
||||
@@ -520,7 +520,7 @@
|
||||
var xwfSlug = testTag + '-xvisitor';
|
||||
var chA = null, chB = null;
|
||||
|
||||
await T.test('crud', 'workflows', 'xvisitor: setup form_only workflow', async function () {
|
||||
await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () {
|
||||
var wf = await T.apiPost('/workflows', {
|
||||
name: testTag + ' XVisitor',
|
||||
slug: xwfSlug,
|
||||
@@ -530,7 +530,7 @@
|
||||
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
|
||||
|
||||
await T.apiPost('/workflows/' + xwfId + '/stages', {
|
||||
name: 'Form', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
||||
name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||
form_template: {
|
||||
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
|
||||
}
|
||||
@@ -627,7 +627,7 @@
|
||||
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||
name: 'Intake',
|
||||
ordinal: 0,
|
||||
stage_mode: 'form_only',
|
||||
stage_mode: 'form',
|
||||
history_mode: 'full',
|
||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||
});
|
||||
@@ -637,7 +637,7 @@
|
||||
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||
name: 'Review',
|
||||
ordinal: 1,
|
||||
stage_mode: 'review',
|
||||
stage_mode: 'form',
|
||||
history_mode: 'summary'
|
||||
});
|
||||
T.assertShape(s2, T.S.workflowStage, 'stage2');
|
||||
@@ -650,7 +650,7 @@
|
||||
// Use the ICD test runner's own package ID (always installed when tests run).
|
||||
var realPkgId = 'icd-test-runner';
|
||||
var stageBase = {
|
||||
name: 'Intake', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
||||
name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||
};
|
||||
|
||||
@@ -720,8 +720,8 @@
|
||||
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
|
||||
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
|
||||
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
|
||||
T.assert(stList[0].stage_mode === 'form_only', 'stage 0 mode should be form_only');
|
||||
T.assert(stList[1].stage_mode === 'review', 'stage 1 mode should be review');
|
||||
T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form');
|
||||
T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
@@ -50,16 +49,14 @@
|
||||
"ordinal": 1,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "webhook-notifier:on_fire"
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "review",
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Armature — 018 Stage Mode Collapse
|
||||
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||
|
||||
-- 1. Migrate existing review rows to form
|
||||
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||
|
||||
-- 2. Replace CHECK constraint on stage_mode (drop review)
|
||||
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_mode_check;
|
||||
ALTER TABLE workflow_stages ADD CONSTRAINT workflow_stages_stage_mode_check
|
||||
CHECK (stage_mode IN ('form', 'delegated', 'automated'));
|
||||
|
||||
-- 3. Drop CHECK constraint on stage_type (any value accepted)
|
||||
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_type_check;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Armature — 018 Stage Mode Collapse
|
||||
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||
-- SQLite cannot ALTER CHECK constraints; app layer prevents new "review" inserts.
|
||||
|
||||
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||
@@ -18,7 +18,7 @@ func testEngine(t *testing.T) *workflow.Engine {
|
||||
return workflow.NewEngine(s, nil, nil)
|
||||
}
|
||||
|
||||
// seedEngineFixture creates a 3-stage workflow (form → review → form),
|
||||
// seedEngineFixture creates a 3-stage workflow (form → form → form),
|
||||
// publishes it, and returns (workflowID, userID, teamID).
|
||||
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
||||
t.Helper()
|
||||
@@ -42,7 +42,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
@@ -134,8 +134,8 @@ func TestEngine_BranchRouting(t *testing.T) {
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
@@ -184,7 +184,7 @@ func TestEngine_PublicEntry(t *testing.T) {
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
@@ -253,7 +253,7 @@ func TestEngine_SignoffGate(t *testing.T) {
|
||||
"validation": map[string]any{"required_approvals": 2},
|
||||
})
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
@@ -307,7 +307,7 @@ func TestEngine_SignoffRejection(t *testing.T) {
|
||||
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
||||
})
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
@@ -590,7 +590,7 @@ func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
||||
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
||||
|
||||
@@ -55,7 +55,6 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
||||
"ordinal": s.Ordinal,
|
||||
"stage_mode": s.StageMode,
|
||||
"audience": s.Audience,
|
||||
"stage_type": s.StageType,
|
||||
"auto_transition": s.AutoTransition,
|
||||
}
|
||||
if s.AssignmentTeamID != nil {
|
||||
@@ -230,6 +229,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
StarlarkHook: s.StarlarkHook,
|
||||
SLASeconds: s.SLASeconds,
|
||||
}
|
||||
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeDelegated
|
||||
}
|
||||
@@ -284,7 +284,7 @@ type workflowPkgStage struct {
|
||||
Ordinal int `json:"ordinal"`
|
||||
StageMode string `json:"stage_mode"`
|
||||
Audience string `json:"audience"`
|
||||
StageType string `json:"stage_type"`
|
||||
StageType string `json:"stage_type"` // deprecated — kept for backward-compat deserialization
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
|
||||
@@ -69,7 +69,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s
|
||||
WorkflowID: wf.ID,
|
||||
Ordinal: 1,
|
||||
Name: "review",
|
||||
StageMode: models.StageModeReview,
|
||||
StageMode: models.StageModeForm,
|
||||
Audience: models.AudienceTeam,
|
||||
StageType: models.StageTypeSimple,
|
||||
AssignmentTeamID: &teamID,
|
||||
|
||||
@@ -202,11 +202,12 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeDelegated
|
||||
}
|
||||
if !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||
return
|
||||
}
|
||||
if st.Audience == "" {
|
||||
@@ -219,18 +220,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
if st.StageType == "" {
|
||||
st.StageType = models.StageTypeSimple
|
||||
}
|
||||
if !models.ValidStageTypes[st.StageType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||
return
|
||||
}
|
||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
||||
return
|
||||
}
|
||||
if st.Ordinal == 0 {
|
||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||
st.Ordinal = len(existing)
|
||||
@@ -252,26 +245,19 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
||||
}
|
||||
st.ID = c.Param("sid")
|
||||
st.WorkflowID = c.Param("id")
|
||||
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||
return
|
||||
}
|
||||
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
||||
return
|
||||
}
|
||||
if st.StageType != "" && !models.ValidStageTypes[st.StageType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||
return
|
||||
}
|
||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||
return
|
||||
|
||||
@@ -56,9 +56,9 @@ type WorkflowStage struct {
|
||||
Name string `json:"name"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
FormTemplate json.RawMessage `json:"form_template"`
|
||||
StageMode string `json:"stage_mode"` // form | review | delegated | automated
|
||||
StageMode string `json:"stage_mode"` // form | delegated | automated
|
||||
Audience string `json:"audience"` // team | public | system
|
||||
StageType string `json:"stage_type"` // simple | dynamic | automated
|
||||
StageType string `json:"stage_type"` // deprecated — retained for backward compatibility
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
StageConfig json.RawMessage `json:"stage_config"`
|
||||
BranchRules json.RawMessage `json:"branch_rules"`
|
||||
@@ -72,7 +72,6 @@ type WorkflowStage struct {
|
||||
|
||||
const (
|
||||
StageModeForm = "form"
|
||||
StageModeReview = "review"
|
||||
StageModeDelegated = "delegated"
|
||||
StageModeAutomated = "automated"
|
||||
)
|
||||
@@ -80,12 +79,22 @@ const (
|
||||
// ValidStageModes is the set of valid stage_mode values.
|
||||
var ValidStageModes = map[string]bool{
|
||||
StageModeForm: true,
|
||||
StageModeReview: true,
|
||||
StageModeDelegated: true,
|
||||
StageModeAutomated: true,
|
||||
}
|
||||
|
||||
// ── Stage Type Constants ────────────────────
|
||||
// NormalizeStageModeInput maps deprecated stage_mode values to their
|
||||
// replacements. "review" → "form"; all others pass through unchanged.
|
||||
func NormalizeStageModeInput(mode string) string {
|
||||
if mode == "review" {
|
||||
return StageModeForm
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// ── Stage Type Constants (deprecated) ───────
|
||||
// stage_type is redundant with starlark_hook presence and is no longer
|
||||
// validated. Constants are retained for backward-compatible references.
|
||||
|
||||
const (
|
||||
StageTypeSimple = "simple"
|
||||
@@ -93,13 +102,6 @@ const (
|
||||
StageTypeAutomated = "automated"
|
||||
)
|
||||
|
||||
// ValidStageTypes is the set of valid stage_type values.
|
||||
var ValidStageTypes = map[string]bool{
|
||||
StageTypeSimple: true,
|
||||
StageTypeDynamic: true,
|
||||
StageTypeAutomated: true,
|
||||
}
|
||||
|
||||
// ── Audience Constants ──────────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -921,7 +921,7 @@ type WorkflowPageData struct {
|
||||
WorkflowDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // form | review | delegated | automated
|
||||
StageMode string // form | delegated | automated
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
||||
TotalStages int
|
||||
@@ -948,7 +948,7 @@ type WorkflowLandingPageData struct {
|
||||
PersonaName string
|
||||
PersonaIcon string
|
||||
StageCount int
|
||||
FirstStageMode string // form | review | delegated | automated
|
||||
FirstStageMode string // form | delegated | automated
|
||||
ResumeURL string // non-empty if visitor has an active session
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
{{end}}
|
||||
|
||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else if eq .Data.FirstStageMode "review"}}Start Review{{else}}Start{{end}}
|
||||
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else}}Start{{end}}
|
||||
</button>
|
||||
|
||||
{{if .Data.ResumeURL}}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
||||
<!-- Stage surface: form, delegated (custom surface), or automated -->
|
||||
|
||||
{{if .Data.AudienceMismatch}}
|
||||
<div class="wf-submitted">
|
||||
@@ -195,11 +195,6 @@
|
||||
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||
{{else if eq .Data.StageMode "form"}}
|
||||
<div class="wf-form" id="formArea"></div>
|
||||
{{else if eq .Data.StageMode "review"}}
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
||||
</div>
|
||||
{{else if eq .Data.Status "completed"}}
|
||||
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
||||
<h3>Completed</h3>
|
||||
@@ -212,7 +207,7 @@
|
||||
{{end}}
|
||||
|
||||
<div class="wf-session-info">
|
||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -542,116 +537,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Review surface (structured review with side-by-side + comments) ──
|
||||
if (STAGE_MODE === 'review') {
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
if (dataPanel && actionPanel) {
|
||||
loadReviewSurface(dataPanel, actionPanel);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
||||
|
||||
// Left panel: structured data card
|
||||
var html = '<div style="padding:24px">';
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + API_ID, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (resp.ok) {
|
||||
var status = await resp.json();
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
||||
var val = status.stage_data[key];
|
||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
dataPanel.innerHTML = html;
|
||||
|
||||
// Right panel: comment input + approve/reject buttons
|
||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
||||
actHtml += '<div style="margin-bottom:16px">';
|
||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="display:flex;gap:8px">';
|
||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject</div>';
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
document.getElementById('reviewRejectBtn').click();
|
||||
} else {
|
||||
document.getElementById('reviewAdvanceBtn').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (r.ok) {
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
alert('Failed: ' + (err.error || 'unknown'));
|
||||
document.getElementById('reviewAdvanceBtn').disabled = false;
|
||||
}
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
|
||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
||||
var comment = document.getElementById('reviewComment').value;
|
||||
var reason = comment || prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
try {
|
||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
|
||||
});
|
||||
if (r.ok) {
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
alert('Failed: ' + (err.error || 'unknown'));
|
||||
document.getElementById('reviewRejectBtn').disabled = false;
|
||||
}
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -60,7 +60,7 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
||||
d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal))
|
||||
d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode))
|
||||
d.SetKey(starlark.String("audience"), starlark.String(s.Audience))
|
||||
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType))
|
||||
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType)) // deprecated — kept for backward compat
|
||||
d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition))
|
||||
if s.StarlarkHook != nil {
|
||||
d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook))
|
||||
|
||||
@@ -437,13 +437,14 @@ components:
|
||||
type: integer
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
enum: [form, delegated, automated]
|
||||
audience:
|
||||
type: string
|
||||
enum: [team, public, system]
|
||||
stage_type:
|
||||
type: string
|
||||
enum: [simple, dynamic, automated]
|
||||
deprecated: true
|
||||
description: Deprecated — retained for backward compatibility. Automation is determined by starlark_hook presence.
|
||||
form_template:
|
||||
type: object
|
||||
stage_config:
|
||||
@@ -1777,7 +1778,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
enum: [form, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -1817,7 +1818,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
enum: [form, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3209,7 +3210,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
enum: [form, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3250,7 +3251,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
enum: [form, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestStageCRUD(t *testing.T) {
|
||||
WorkflowID: wf.ID,
|
||||
Ordinal: 1,
|
||||
Name: "Review",
|
||||
StageMode: "review",
|
||||
StageMode: "form",
|
||||
}
|
||||
s.CreateStage(ctx, stage2)
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestStageCRUD(t *testing.T) {
|
||||
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
||||
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
||||
}
|
||||
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
|
||||
if stages[1].Name != "Review" || stages[1].StageMode != "form" {
|
||||
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestStageReorder(t *testing.T) {
|
||||
s.Create(ctx, wf)
|
||||
|
||||
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
||||
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
|
||||
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "form"}
|
||||
s.CreateStage(ctx, s1)
|
||||
s.CreateStage(ctx, s2)
|
||||
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||
export const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||
export const STAGE_MODES = ['form', 'delegated', 'automated'];
|
||||
export const AUDIENCES = ['team', 'public', 'system'];
|
||||
|
||||
export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
@@ -60,7 +58,6 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
name,
|
||||
stage_mode: mode,
|
||||
audience,
|
||||
stage_type: stageType,
|
||||
starlark_hook: starlarkHook || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
auto_transition: autoTransition,
|
||||
@@ -91,14 +88,8 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
${mode === 'automated' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
|
||||
Reference in New Issue
Block a user