Outbound email reachable from scripts as email::send(#{...}) (plain
text) and email::send_html(#{...}) (multipart text + HTML). Backed by a
lettre SMTP relay configured from PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
TLS/TIMEOUT_SECS; if HOST/USER/PASSWORD aren't all set the service runs
in disabled mode (every send throws NotConfigured, warned at startup).
- EmailService trait + OutboundEmail DTO (picloud-shared);
EmailServiceImpl + EmailTransport seam + lettre transport
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppEmailSend (→ script:write); seven-scope commitment held.
- Required-field + RFC5322-ish address validation; 25 MB per-message cap
(PICLOUD_EMAIL_MAX_MESSAGE_BYTES). reply_to defaults to from.
- Per-call connection (pooling deferred to v1.2); no per-app from
validation (operator's SMTP/SPF/DKIM concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add picloud_shared::crypto: AES-256-GCM encrypt/decrypt envelope
(12-byte CSPRNG nonce, 128-bit tag appended to ciphertext) plus a
MasterKey sourced from PICLOUD_SECRET_KEY (base64 of 32 bytes), with
a deterministic dev-key fallback gated on PICLOUD_DEV_MODE=true. Unset
key without dev mode is fatal. Key rotation is out of v1.1.7 scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Workspace `1.1.2` → `1.1.3` (`Cargo.toml`).
- Dashboard `0.8.0` → `0.9.0` (`package.json`).
- CHANGELOG: full v1.1.3 entry covering ScriptKind, ModuleSource,
PicloudModuleResolver, the two caches, dep-graph table, route +
trigger module rejection, the latent cross-app trigger gap that
this release closes, migrations 0015/0016, and downgrade caveats.
- Blueprint: mark the "Can scripts `import` Rhai modules?" question
as resolved; one-line pointer to the v1.1.3 semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New `ScriptIdentity { script_id, updated_at }` DTO.
- `ExecutorClient` trait gains an `execute_with_identity` method;
default impl forwards to `execute` so `RemoteExecutorClient` (and
cluster-mode transports later) keep working without bespoke caching.
- `LocalExecutorClient` overrides `execute_with_identity` to consult
an `LruCache<ScriptId, CachedScript>`. Cache hit only when the
cached entry's `updated_at` matches the caller's identity; mismatch
triggers a fresh `Engine::compile`. `Engine::execute_ast(&Arc<AST>, req)`
is called inside `spawn_blocking` exactly as `execute` does today.
- Cache size from `PICLOUD_SCRIPT_CACHE_SIZE` (default 256).
- Orchestrator's HTTP data-plane path and the dispatcher both switch
to `execute_with_identity`. `ResolvedTrigger` carries
`script_updated_at` for the dispatcher's identity construction.
Workspace builds; full test suite (~440 tests) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays down the v1.1.3 plumbing:
- `ScriptKind` enum in `picloud-shared` ('endpoint' | 'module').
- `ModuleSource` trait + `ModuleScript` DTO + `NoopModuleSource` in
`picloud-shared`. Resolver lives in `executor-core`; Postgres impl
in `manager-core` (`PostgresModuleSource`).
- `Services::new` grows a fifth `modules: Arc<dyn ModuleSource>` arg.
- `ScriptValidator` returns `ValidatedScript { imports }` so the
manager can populate the dep-graph table on save. New
`validate_module` method on the trait gates module-shape rules.
- `Engine::execute_ast(&Arc<rhai::AST>, req)` lets the orchestrator's
script cache reuse compiled ASTs. `Engine::execute(&str, req)` is
preserved as a convenience that compiles inline. `Engine::compile`
exposes the AST for callers that want to cache.
- `PicloudModuleResolver` replaces `DummyModuleResolver` per-call.
Bridges Rhai's sync `ModuleResolver::resolve` to async
`ModuleSource::lookup` via `Handle::block_on`. Enforces:
- cross-app isolation (resolver captures `Arc<SdkCallCx>`),
- circular import detection (in-progress stack on the resolver),
- import depth limit (default 8 via
`Limits::module_import_depth_max`).
- Module-shape validation walks `ast.statements()` via `rhai/internals`
and accepts only `Var { CONSTANT }`, `Import`, and `Noop`. The
manager admin endpoint runs `validate_module` at save (primary
gate); resolver re-runs it at load (defense in depth).
- LRU cache `(AppId, name) -> (updated_at, Arc<Module>)` owned by
`Engine`. Size from `PICLOUD_MODULE_CACHE_SIZE` (default 512).
- Migration `0015_scripts_kind.sql` adds `scripts.kind` + composite
index + module-name shape CHECK.
- Migration `0016_script_imports.sql` adds the dep-graph table with
FK CASCADE on both columns.
- Repo: `kind` threaded through SELECT/INSERT/UPDATE. New
`count_routes_for_script` / `count_triggers_for_script` /
`list_imports` methods. `create`/`update` open a transaction and
call `replace_imports_tx` to populate the dep-graph.
- Admin endpoint: accepts `kind`; rejects reserved module names;
rejects `endpoint → module` transitions when routes / triggers
exist.
- SDK_VERSION 1.3 → 1.4.
Workspace builds; full test suite (~440 tests) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace package version 1.1.1 -> 1.1.2; dashboard 0.7.0 -> 0.8.0
(workspace alignment, no docs-specific UI yet); SDK_VERSION
1.2 -> 1.3 for the docs:: surface + ctx.event.docs additions.
CHANGELOG entry documents the docs store, the query DSL subset, the
docs:* trigger kind, the prev_data change-data-capture surface, and
the new AppDocsRead/AppDocsWrite capabilities. Includes a downgrade
caveat (v1.1.2 -> v1.1.1 with queued docs outbox rows would fail
TriggerEvent deserialization) and known-limitations notes for the
text-lex comparison gotcha and the concurrent-update prev_data race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Workspace package version: 1.1.0 → 1.1.1 (patch under the
post-1.0 expansion-phase carve-out in docs/versioning.md)
- Rhai SDK version: 1.1 → 1.2 — minor bump, additive only.
New surfaces: kv::*, dead_letters::*, ctx.event.
- Dashboard package version: 0.6.0 → 0.7.0 for the dead-letters UI.
- HTTP API version stays at 1 (additive: trigger CRUD, dead-letter
admin endpoints, dispatch_mode field on routes).
- Schema version: 6 → 12 (migrations 0007–0012).
CHANGELOG.md created at the repo root following the convention from
prior bumps (release commits + design-notes references).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`OutboxEventEmitter` replaces `NoopEventEmitter` in the picloud
binary's `Services` bundle. KV mutations now fan out to the outbox
via `TriggerRepo::list_matching_kv` — one row per matching trigger,
carrying the serialized `TriggerEvent` payload + the matching
trigger's retry policy.
`Dispatcher` is the single tokio task that polls the outbox every
100ms, claims due rows via FOR UPDATE SKIP LOCKED (with a batch cap),
and routes each to the executor. Shares the `ExecutionGate` with
sync HTTP per design notes §2 — gate saturation reschedules the
row instead of dropping it.
Outcome handling matches design notes §3 and §4:
- reply_to.is_some() (sync HTTP): never retry. Deliver via
`InboxResolver`; if the receiver was dropped, write an
`abandoned_executions` row.
- is_dead_letter_handler == true: never retry, never DL. On
failure, annotate the original DL row with
`resolution = 'handler_failed'`. Stops the recursion that would
otherwise re-fire a broken handler script.
- Otherwise async: bump attempt_count, reschedule with exponential
backoff + ±jitter; once max_attempts is reached, write a
`dead_letters` row and drop from outbox.
- Trigger-depth limit: `cx.trigger_depth > max_trigger_depth` skips
execution entirely (log + future metric), NEVER dead-letters.
Loops are not retried via the DL chain — they're terminated.
`InboxResolver` trait lands in `picloud-shared` with a
`NoopInboxResolver` bootstrap that flags every delivery as
`Abandoned`. Commit 6 replaces the noop with the real
in-process registry in `orchestrator-core`.
`AdminPrincipalResolver` builds a `Principal` from a trigger's
`registered_by_principal` user id so the dispatched script executes
as the trigger registrant (design notes §4).
Unit tests cover backoff math (exponential/linear/constant) +
jitter range + ExecError → InboxFailureKind classification + the
status-code table mapping. Integration tests for the full
dispatcher loop need a real Postgres + executor; reviewer runs them
via the manual smoke flow in the plan / HANDBACK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the KV store into Rhai scripts via the handle pattern:
let widgets = kv::collection("widgets");
widgets.set("k", #{ n: 1 });
let v = widgets.get("k"); // value or () if absent
widgets.has("k") / widgets.delete("k")
let page = widgets.list(); // cursor-style pagination
`KvHandle` is a custom Rhai type holding `Arc<dyn KvService>` + the
per-call `Arc<SdkCallCx>`. Methods route async service calls through
`tokio::Handle::current().block_on(...)` — works because
`LocalExecutorClient` runs the script under `spawn_blocking` so a
runtime is reachable. The bridge surfaces `app_id` exclusively
through `cx.app_id`; no public-facing argument can spoof an app.
`TriggerEvent` lands in `picloud-shared` as the wire shape the
dispatcher will emit (KV + DeadLetter variants — KV exercised now,
DL hooks up with the dispatcher in commit 5/8). `SdkCallCx` and
`ExecRequest` grow `is_dead_letter_handler: bool` and
`event: Option<TriggerEvent>`. `engine.rs::build_ctx_map` flattens
the event into `ctx.event` for triggered handlers; direct ingress
leaves the key absent so scripts can `if "event" in ctx`.
Tests:
- 7 `sdk_kv.rs` integration tests covering the full Rhai surface
(round-trip, missing-key unit, has bool, delete was-present,
empty-collection rejection, cursor pagination, cross-app
isolation through the bridge).
- 3 new `engine.rs` tests pinning `ctx.event` shape per
design notes §4 (KV insert with value, delete with unit value,
direct invocations have no `event` key).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the Cargo package version with the blueprint roadmap labels.
v1.1.0 = SDK foundation (#0) + stdlib utilities (#0.5), the first
release of the Phase 4 / v1.1 series.
Also updates docs/versioning.md:
- Current versions table: Product 0.6.0 -> 1.1.0
- Docker / Git tag examples: 0.2.0 -> 1.1.0
Cargo.lock regenerated by `cargo check --workspace`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace deps for the seven Rhai utility modules that follow in this
PR. `rand`, `base64`, `uuid`, `chrono`, `serde_json` are already in
the workspace and reused as-is — only the genuinely new ones land here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the review findings on the CLI surface:
* `pic login` now prompts for username + password and POSTs to
`/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still
works for paste-a-bearer flows (CI, long-lived API keys). Falls
back to a plain stdin read when no controlling tty is attached.
* `pic logout` revokes the session server-side and deletes the local
credentials file. Idempotent.
* `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials
file for every command via `config::resolve`, not just for
`pic login`. Matches gcloud/aws/kubectl semantics.
* New commands: `pic apps delete [--force]`, `pic apps show`,
`pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level
`pic invoke` / `pic deploy` shortcuts.
* `pic scripts ls` (no `--app`) now issues a single
`GET /admin/scripts` + one `apps_list` in parallel and joins
client-side, instead of walking N+1 per-app calls that aborted on
the first 404 — the bug the test suite was retrying around.
* Global `--output tsv|json` flag wired through every list/show and
through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real
array of objects (or a flat object for single-row views).
* `whoami` and `logs` now emit labeled output instead of headerless
tab lines, consistent with the existing `apps ls` / `scripts ls`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single bare-metal integration test now reuses a `LazyLock<Fixture>`
that spawns picloud once on a private port and shares it across every
test in the binary. Sets the stage for per-surface journey modules
(auth, apps, scripts, invoke, logs, roles, output) without each one
paying for its own server spawn — same trick the dashboard Playwright
suite uses with global-setup.
Notes:
- `tests/cli.rs` becomes a tiny module list; the seed flow moved to
`tests/integration.rs`. The seed slug now goes through
`common::unique_slug` so parallel/serial reruns can't collide.
- `autotests = false` + an explicit `[[test]] name = "cli"` keeps Cargo
from auto-promoting future `tests/*.rs` files into their own binaries
(which would each respawn picloud).
- Subprocess cleanup uses `libc::atexit` to SIGTERM picloud when the
test binary exits. PR_SET_PDEATHSIG was tried and rejected: it fires
when the *thread* that forked dies, and cargo's per-test worker
threads exit between tests, which killed the fixture mid-suite.
- New helpers: AppGuard/UserGuard (RAII teardown), member_user /
grant_membership / update_membership (direct API for role tests),
unique_slug / unique_username, pic_as / pic_no_env.
- Two `fixture_url_is_shared_*` tests prove the LazyLock is actually
shared, not respawned per test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:
pic login (paste URL + bearer token, validates via /auth/me)
pic whoami (re-validates and prints principal)
pic apps ls | create
pic scripts ls | deploy | invoke
pic logs <id>
Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.
The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the matrix laid out in the plan:
* bootstrap admin lands as Owner
* owner / admin / member access matrices on the default app
* bearer pic_ key and cookie session resolve to the same Principal
* read-only key cannot write (scope intersection)
* bound key cannot escape its app
* member listing isolation at SQL for /admin/apps + /admin/scripts
* deactivating a user expires every API key for them
* mint rejects bound key carrying instance:* scopes (422)
* list_active_owners returns the right set for the startup warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3.5 ships → product minor bump under pre-1.0 rules (any surface
bump triggers minor). Schema is now 6 (0006_users_authz.sql); API
remains 1 (additive endpoints + new credential type, no breaking
shape changes). docs/versioning.md updated.
main.rs gets warn_on_multi_owner_install() which fires once after
bootstrap when more than one active owner exists — points the
operator at PATCH /admin/admins/{id} for cleanup. Soft-fail on DB
error (does not block startup).
The api-test schema assertion was updated to expect 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
indexed 8-char prefix, and Argon2-hashes the body. Adds the
data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
exactly once), GET (caller's own, no raw), DELETE {id} (caller's
own; 404 deliberately covers both 'missing' and 'not yours').
Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
expires every active key for that user alongside session wipe —
Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
AdminsState + ApiKeysState; api_keys_router merges into the
guarded_admin layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the Rhai formatter shipped in 0.5.1.
* Formatter no longer collapses user-intent blank lines between
statements. The lexer now records a side-channel list of offsets
where the source contained two-or-more consecutive newlines; the
formatter consults it and emits a single blank in the same spot
(rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
the prior forced blank between top-level `fn` decls is dropped, so
the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
an optional `role` hint and each call site supplies a domain phrase
(`name of a variable`, `function name in function declaration`,
`'{' to begin a block`, `name of a property`, …). End-of-input is
reported as `script is incomplete`. The dashboard banner renders
`Parse error: {message} (line L, position C)` with 1-based
coordinates, matching Rhai's format exactly.
The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.
Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the script detail page from one wall of cards into a
focused four-tab layout. Each tab does one thing well:
* Edit source editor + Test invoke panel (the existing
quick-iteration loop, unchanged in shape)
* Routing routes table + add-route form with live kind
auto-detection + match-preview tool
* Settings name / description / timeout, plus a collapsible
"Advanced sandbox" disclosure with the six Rhai
limits (admin-ceiling-validated server-side)
* Executions the existing log viewer, gets the room it deserves
Why four tabs (not two columns, not one tab per surface, not URL
sub-routes):
* Two columns crowded out the routing UX, which is genuinely
list+form-shaped and wants vertical space.
* URL sub-routes (/scripts/{id}/routing) would let users
bookmark a tab but cost a bunch of router files for a
feature nobody asked for; tab state in $state is one line
and gets us 95% of the UX.
* Sandbox config tucked inside Settings' "Advanced" disclosure
matches the 95%-never-touch reality without hiding it.
Routing tab highlights:
* Path input + path-kind selector with live auto-detect: type
/greet/:name and the kind selector flips to "param"; the
user can override and the backend trusts the override (lets
/greet/:name be matched literally if exact is selected).
* Soft warning when selection mismatches the input shape — not
blocking, with the rationale in the message.
* Host kind auto-detect mirrors the path side (*.example.com →
wildcard; non-empty literal → strict; empty → any).
* Method dropdown defaults to ANY.
* 409 conflicts render inline with the conflicting route's
method/host/path so the user can read both at a glance.
* Match-preview panel: synthetic URL + method → "matches THIS
script" or "matches a DIFFERENT script" or "no match", with
extracted params shown. Powered by /api/v1/admin/routes:match.
* Full URLs in the routes list use the operator's
PICLOUD_PUBLIC_BASE_URL from /version.
Settings tab:
* Name / description / timeout editable from the UI for the
first time (previously only source was).
* "Advanced sandbox" details element collapsed by default;
six number inputs with placeholder "(default)" meaning
"use platform default for this knob". Save sends a fresh
sandbox object; ceiling errors surface as the manager's 422
message inline.
API client (dashboard/src/lib/api.ts):
* Adds `Route`, `RouteInput`, `CheckRouteResponse`,
`MatchRouteResponse`, `ScriptSandbox`, `VersionInfo` types.
* Adds `api.routes.{listForScript,create,remove,check,match}`.
* Adds `api.version()`.
* `UpdateScriptInput` gains `sandbox`.
route-utils.ts:
* `guessPathKind`, `guessHostKind` heuristics.
* `pathKindMismatchWarning` for the soft-warning UX.
Verified live:
/admin/ → 200, dashboard HTML
/admin/scripts/<id> → SPA detail with all four tabs
POST /api/v1/admin/scripts/<id>/routes → 201, route persists
GET /demo/widget → 200 (script answers via routing
table after the dashboard's
POST refreshes it)
/version → public_base_url, schema 3, sdk 1.1
No backend changes — Commit 3 is purely additive UI on top of the
infrastructure Commits 1 and 2 already shipped.
Bumps: product 0.4.0 → 0.5.0 (user-visible UX change). Schema, SDK,
API, wire unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scripts can now answer at user-chosen paths (e.g. /greet, /greet/:name,
/webhooks/*), on user-chosen hosts (strict or *.example.com wildcards),
on user-chosen methods. The internal /api/v1/execute/{id} endpoint
stays as the always-available ID-based bypass.
Routing rules (decided in design with the user; see chat history):
Path kinds:
exact /greet literal
prefix /greet/* strict-subtree; stored as "/greet/";
does NOT match bare /greet (add an
exact route for that case)
param /users/:id :name captures one whole segment;
mid-segment colons are rejected;
{name} is reserved for a future SDK
Host kinds:
any no Host header constraint
strict sub.example.com literal match (case-insensitive)
wildcard *.example.com suffix match; multi-level subdomains OK
Within-kind uniqueness:
two routes of the same kind that could match the same request
conflict at config time. Algorithm (orchestrator_core::routing::
conflict):
exact: literal equality
prefix: literal equality (longer-prefix coexists; longer wins
at request time)
param: same segment count + same literals at every
literal-vs-literal position (the user's example:
:id vs :userId at same shape is a conflict)
Request-time precedence:
exact > param > prefix
among non-exact: more leading-literal segments wins
tie: param > prefix (more constrained)
within prefix: longest matching prefix wins
host bucket: strict > wildcard (longer suffix) > any; fall through
to less specific buckets when path doesn't match
Reserved path prefixes: /api/, /admin/, /healthz, /version
Routes that look invalid at config time return 422 with the precise
parse error; conflicting routes return 409 with the conflicting route
in the body (so the dashboard can render the conflict inline).
What landed:
* 0003_routes.sql — routes table (host_kind, host, host_param_name,
path_kind, path, method, script_id) with UNIQUE index on the
literal binding tuple. Schema 2 → 3.
* shared::Route / HostKind / PathKind — flat storage shape that
crosses wire boundaries cleanly.
* orchestrator_core::routing — four sub-modules, all unit-tested:
pattern.rs (16 tests) parse + validate + display
conflict.rs (12 tests) within-kind overlap predicate
matcher.rs (12 tests) runtime dispatch (specificity-aware)
table.rs Arc<RwLock<Vec<CompiledRoute>>>
shared by manager (writes) and
orchestrator (reads); atomic replace
after each admin write
* manager-core::route_admin — five new admin endpoints under
/api/v1/admin:
POST /scripts/{id}/routes create
GET /scripts/{id}/routes list per script
DELETE /routes/{route_id} delete (refreshes table)
POST /routes:check pre-flight conflict check
(powers the dashboard's
live conflict warning)
POST /routes:match synthetic URL → matched
route + extracted params
(powers the dashboard's
match-preview tool)
Stored path strings stay raw (user-typed); normalization
happens only in the in-memory CompiledRoute so re-parses are
idempotent.
* orchestrator_core::api::user_routes_router — fallback handler
mounted in picloud after the system routes. Reads Host /
method / path / query from the request, dispatches via the
table, builds an ExecRequest with params/query/rest filled,
calls the executor, writes to the log sink. 10 MiB body cap.
* executor-core::ctx (SDK 1.0 → 1.1) — adds
ctx.request.params (map of named-param captures)
ctx.request.query (parsed query string)
ctx.request.rest (suffix for prefix routes; "" otherwise)
All three are always present (empty when not applicable) so
scripts can read them unconditionally.
* picloud::build_app — now async; loads routes at startup,
populates the shared table, mounts route_admin_router under
/api/v1/admin alongside the script CRUD, and the user-routes
fallback at the app root.
* caddy/Caddyfile + Caddyfile.prod widened: anything not
/healthz, /version, /api/v1/admin/*, /api/v1/execute/*,
/api/* (404 sunset), or /admin/* (dashboard) → picloud.
* Dashboard moves to /admin/* via SvelteKit paths.base. Its
internal Caddy strips the prefix and serves with SPA fallback.
All in-app links use $app/paths. The dashboard URL is now
http://localhost:8000/admin/ — one-time break for the new
URL freedom users gained.
* PICLOUD_PUBLIC_BASE_URL env var, exposed via /version so the
dashboard renders full URLs for routes regardless of the
operator's external port / TLS setup.
* memory_limit_mb stays in the schema, still v1.3+ advisory.
Verified live through Caddy:
/version → schema 3, sdk 1.1, public_base_url
GET /admin/ → 200, dashboard HTML containing "PiCloud"
POST /api/v1/admin/scripts → 201
POST .../scripts/{id}/routes (path=/greet/:name) → 201
GET /greet/alice?lang=en → 200 {"name":"alice","q":"en"}
POST conflicting route → 409 with conflicting_route body
POST /admin/foo route → 422 "reserved"
POST /api/v1/admin/routes:match → matched + params extracted
GET /unbound-path → 404 JSON
Tests:
* 40 routing unit tests (pattern + conflict + matcher tables)
* 14 executor-core unit tests (one new for ctx.request.params/
query/rest exposure)
* 32 integration tests (10 new for routing CRUD + dispatch +
conflict + reserved + specificity tie-break + match preview +
delete invalidation + /version returns public_base_url)
* default cargo test --workspace stays green; opt-in via
DATABASE_URL + --include-ignored for the integration suite
Bumps: schema 2 → 3; SDK 1.0 → 1.1; product 0.3.0 → 0.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establish how versions are assigned, bumped, and checked across the
five things that actually change for users: the product itself, the
Rhai SDK, the HTTP API, the database schema, and the inter-service
wire (reserved for cluster mode). Crates ship in lockstep — drift
between picloud-shared and picloud-manager-core is fiction since
they always release together — but surfaces are versioned and
checked at their natural boundaries.
* docs/versioning.md is the authoritative reference: what gets a
version, the per-surface compatibility rules, how each surface
bump cascades to the product version (loose pre-1.0, strict
post-1.0), and the five enforcement mechanisms (lockstep at
compile time, /version at runtime, golden SDK contract tests,
migration replay, CI guardrail).
* shared::version exposes four constants — PRODUCT_VERSION (from
CARGO_PKG_VERSION), SDK_VERSION ("1.0"), API_VERSION (1),
WIRE_VERSION (1). Scripts read SDK_VERSION as ctx.sdk_version
and can feature-detect against it.
* Workspace inheritance: `[workspace.package] version = "0.2.0"`
is the single point of truth; every crate uses
`version.workspace = true`. dashboard/package.json mirrors.
* Routes move to /api/v1/* — both control plane
(/api/v1/admin/*) and data plane (/api/v1/execute/{id}).
Picloud composes them via a single `/api/v{API_VERSION}` nest,
so the next major is a copy-paste-and-bump. Caddyfile (dev and
prod) routes /api/v1/* to picloud and 404s any other /api/*
so old clients fail loudly instead of getting the SPA shell.
Dashboard client + integration tests updated.
* /healthz remains a plain "ok" string (k8s probes); /version is
the new JSON endpoint returning every surface version in one
place — product, sdk, api, schema (from
manager-core::migrations::latest_version), wire.
* Reasonable bump rationale: API path changes are breaking by
definition, so 0.1.0 → 0.2.0 (pre-1.0 license to bump minor on
any breaking change). SDK starts at 1.0 because scripts depend
on it more strictly than the product depends on its internals;
we'd rather promise SDK stability early than pull the rug.
Verified live:
* /healthz → "ok" (plain text)
* /version → {product:"0.2.0",sdk:"1.0",api:1,schema:1,wire:1}
* /api/v1/admin/scripts → 200
* /api/admin/scripts → 404 with error JSON (sunset major)
* Script can read ctx.sdk_version → "1.0"
* All 14 integration tests pass against new paths
* 11 executor-core unit tests pass (added one for sdk_version
exposure with the major.minor format invariant)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three threads landing together because they share a public surface
(the new execution_log shape) and verifying any one in isolation
would mean re-doing the work later.
== (A) execution log persistence ==
* shared::ExecutionLog + ExecutionStatus carry the audit-trail
shape that flows from the orchestrator through the sink and
back out via the manager's logs endpoint.
* shared::ExecutionLogSink trait — abstraction the orchestrator
writes through. In single-process MVP mode the manager's
Postgres-backed impl is plugged in directly; in cluster mode
(v1.3+) the orchestrator's impl will post over HTTP to the
manager. Trait lives in `shared` so neither *-core crate has
to know about the other.
* manager-core::PostgresExecutionLogSink writes to the
execution_logs table (already in the initial migration);
PostgresExecutionLogRepository reads them back, paginated.
AdminState now carries both a script repo and a log repo, so
`admin_router` exposes `GET /scripts/{id}/logs?limit=&offset=`
capped at 200 rows per page to keep the dashboard responsive.
* orchestrator-core::DataPlaneState gains `log_sink`. The
execute handler builds an ExecutionLog on every outcome —
success, error, timeout, budget-exceeded — and awaits the
sink. Sink failures are logged at warn and DO NOT mask the
user-facing result, since "we couldn't write the audit row"
is a separate concern from "the script ran".
* picloud binary refactored into a lib (`build_app(pool)` is
the seam) + thin bin shell. Same Postgres pool backs the
script repo, the log repo, and the sink — no double pool.
== (B) dashboard ==
* Typed API client extended with `scripts.logs(id, opts)`,
`scripts.update/remove`, and `execute(id, body, headers)`.
Plain `fetch` wrapper now surfaces server-side error
messages via a typed ApiError so the UI can render them.
* `/` — create-script form now actually creates; on success
the list reloads. List entries link to detail.
* `/scripts/[id]` — new detail route: source editor with save
(calls update, version bumps); Test invoke panel that sends
arbitrary JSON body + headers to /api/execute and shows the
response; Recent executions panel reading from /logs with
expandable per-row request/response/script-log views.
Delete button with confirm. SPA-routed; Caddy serves
`build/` with the same index.html fallback.
== (C) integration tests ==
* crates/picloud/tests/api.rs — 14 sqlx::test cases driving
`build_app` through an axum_test::TestServer against a fresh
Postgres DB per test. Covers: health, full script CRUD,
duplicate-name conflict, invalid-source rejection on both
create and update, execute echoing the body, status+header
passthrough, 404 on missing scripts, error-path executions
landing in the audit log with the right status.
* Tests are `#[ignore]` by default so plain `cargo test
--workspace` stays green without infrastructure. Opt-in via:
`docker compose up -d postgres && \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo test -p picloud --test api -- --include-ignored`
Verified live through Caddy on :8000: three logged invocations
land in the logs endpoint with the right structured `data` on
each `log::info`/`log::warn`, error-path executions are still
captured with status=error, dashboard list + SPA detail route
both reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the MVP feature set online: upload a Rhai script, get an HTTP
endpoint that runs it sandboxed in-process, list/update/delete it, and
have invalid sources rejected at upload time. Verified live through
Caddy with a full lifecycle (`create → list → get → execute → update
→ delete`) plus error paths (syntax error, duplicate name, deleted).
Layout — every concern lands behind the trait seam its layer owns, so
cluster-mode in v1.3+ is a swap of two impls, not a rewrite:
* shared::ScriptValidator — manager calls into validation without
a hard dep on executor-core; executor-core impls the trait on
`Engine`. Pinned in shared so neither crate has to know about
the other.
* executor-core::Engine — real Rhai engine: sandbox limits (max
operations / string size / map size / call depth), disabled
`print`, blocked `import` (DummyModuleResolver), `log::trace
/info/warn/error` registered as a static module with shared
log-capture buffer (no `log::debug` because `debug` is a Rhai
reserved keyword — `log::trace` covers the same need).
- `ctx` is pushed as a Scope constant exposing
execution_id, script_id, script_name, request_id,
invocation_type, request.{path,headers,body}.
- Response convention: a Map with `statusCode` is the
structured shape (`{statusCode, headers?, body}`); any
other return value is a 200 with the value as the body.
- Engine::execute is now synchronous (pure compute); the
async wrapper + wall-clock timeout live in
LocalExecutorClient, which spawns_blocking and applies a
300s hard ceiling regardless of per-script config.
- 10 unit tests cover validate, exec, structured response,
ctx exposure, log capture, op-budget enforcement, runtime
errors, blocked imports, JSON round-tripping.
* manager-core::repo — full sqlx CRUD over the `scripts` table,
with proper unique-violation handling for duplicate names.
Embedded migrations via `sqlx::migrate!` (one initial
`0001_init.sql` for pgcrypto + scripts + execution_logs).
* manager-core::api — `admin_router` mounts `/scripts` and
`/scripts/{id}`. Create + Update validate source through the
injected `ScriptValidator` before persistence. Returns proper
422/409/404 status codes via `ApiError::IntoResponse`.
* orchestrator-core::api — `data_plane_router` mounts
`/execute/{id}`: resolves the script through `ScriptResolver`,
constructs the `ExecRequest` from headers+body, awaits
`ExecutorClient::execute(..., timeout)`, translates the
`ExecResponse` to an axum `Response` with header passthrough.
Maps `ExecError` variants to 422/504/502/507.
* picloud all-in-one — opens the pool, runs migrations, builds
one engine, nests both routers under `/api/admin` and `/api`,
enables structured JSON tracing and graceful shutdown on
SIGTERM. Single `PostgresScriptRepository` Arc is shared by
the admin router (writes) and the resolver (reads).
Other changes:
* Workspace axum bump 0.7 → 0.8 for the `{id}` path syntax
matching the route definitions.
* Workspace clippy: allow `needless_pass_by_value` and
`boxed_local` to keep API ergonomics over pedantic noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets up the PiCloud monorepo as a Cargo workspace organised around the
three-service architecture (manager / orchestrator / executor), each
backed by a *-core library crate so the same logic powers both the MVP
all-in-one `picloud` binary and the future split-process cluster mode.
* crates/shared, executor-core, orchestrator-core, manager-core
define the library surface and trait seams between the three
services (`ExecutorClient`, `ScriptResolver`, `ScriptRepository`).
* crates/picloud is the MVP entrypoint; serves /healthz on 8080
(override via PICLOUD_BIND).
* crates/picloud-{manager,orchestrator,executor} are skeleton
binaries that keep the crate boundaries honest until cluster
mode is built out in v1.3+.
* docs/git-workflow.md defines the trunk-based workflow:
short-lived branches, Conventional Commits, separate hotfix
flow with mandatory reproduction tests.
* CLAUDE.md captures the working rules for future Claude sessions.
Workspace passes `cargo fmt`, `cargo clippy -D warnings` (with
pedantic enabled), and `cargo test --workspace`. The all-in-one
binary responds on `/healthz` and `/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>