Design notes §4 makes the dashboard surface load-bearing — with no
default DL handler, users wouldn't know dead letters exist
otherwise.
New route: `apps/[slug]/dead-letters/+page.svelte` — list view
columns per the design notes:
- `created_at`, `source`, `op`, `script_id`, `attempt_count`,
`first/last_attempt_at`, `last_error` (truncated; clickable)
- per-row Replay + Mark resolved buttons
- expandable row detail panel showing full payload (JSON) +
full last_error
- unresolved-only filter (default on); refresh button
Per-app detail page (`apps/[slug]/+page.svelte`) grows a "Dead
letters" link in the tabs nav, with a red unresolved-count pill
when > 0. Loaded in parallel with the existing app loaders so it
doesn't slow the page.
Apps list (`apps/+page.svelte`) shows the same red pill next to
each app's name when its unresolved count > 0. Counts fetched in
parallel after the apps list lands; failures here are non-fatal
(just no badge).
API client wiring: `api.deadLetters.{count,list,get,replay,resolve}`
mirrors the v1.1.1 admin endpoints. `DeadLetterRow` type added to
the dashboard's API shape declarations.
dashboard's svelte-check passes (369 files, 0 errors, 0 warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PostgresDeadLetterService` lands as the real `DeadLetterService`
impl, replacing `NoopDeadLetterService` in the picloud binary's
`Services` bundle. Both methods are gated by
`Capability::AppDeadLetterManage(AppId)` — public-HTTP scripts with
`principal: None` fail the check, per design notes §4.
- `dead_letters::replay(id)` (Rhai SDK + admin endpoint): re-inserts
the original event payload into the outbox with attempt_count=0,
reply_to=None. The DL row is marked `resolution='replayed'`.
- `dead_letters::resolve(id, reason)` (Rhai SDK + admin endpoint):
closes the row with `resolved_at = NOW()` and the given reason.
CHECK constraint on the column enforces the 4-value vocabulary.
- `dead_letters::list(filter)` is intentionally NOT shipped —
design notes §4 defers it to v1.2 to align with the eventual
`docs::find()` query DSL.
Admin endpoints under `/api/v1/admin/apps/{id}/dead_letters/*`:
- `GET /` (with `?unresolved=true`) → list view
- `GET /count` → unresolved-count badge
- `GET /{dl_id}` → row detail (full payload + error)
- `POST /{dl_id}/replay` → re-enqueue
- `POST /{dl_id}/resolve` body `{reason}` → close out
All cross-app-aware: the row's `app_id` is compared against the path
param so a caller with rights on app A cannot manipulate app B's
dead letters by id alone.
The Rhai bridge for `dead_letters::*` follows the same sync↔async
pattern as the `kv::` bridge (`Handle::current().block_on(...)`
inside the spawn_blocking-wrapped Rhai engine).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes gain `dispatch_mode TEXT NOT NULL DEFAULT 'sync'` (migration
0012). Existing routes default to sync so the migration is
non-breaking. `DispatchMode` enum lands in `picloud-shared`.
The user-routes orchestrator handler now branches:
- `dispatch_mode = async` → write outbox row with `reply_to = None`,
return `202 Accepted` + `{accepted_at, execution_id}`. Dispatcher
fires the script in the background; retries / dead-letters via
the framework from commit 5.
- `dispatch_mode = sync` → register an inbox channel
(`tokio::sync::oneshot`), write outbox row with `reply_to =
inbox_id`, `.await` on the receiver with a timeout =
script.timeout_seconds + 2s buffer. Dispatcher hands the result
back; orchestrator maps `InboxResult` into the HTTP response per
the design-notes §3 status-code table (422/502/503/504/507/500).
`InboxRegistry` (orchestrator-core/src/inbox.rs) is the in-process
implementation of `InboxResolver`. Lock-free HashMap of pending
oneshot senders keyed by `inbox_id`. Tests cover register/deliver
round-trip, unknown-id is abandoned, dropped-receiver is abandoned,
explicit cancel. Cluster mode (v1.3+) swaps this for
LISTEN/NOTIFY-keyed lookup behind the same trait.
`OutboxWriter` trait lives in `picloud-shared` so orchestrator-core
can write to the outbox without depending on manager-core (which
would invert the dependency arrow). `PostgresOutboxRepo` implements
both `OutboxRepo` (dispatcher surface) and `OutboxWriter`
(orchestrator surface); the picloud binary clones the same concrete
Arc into both trait views.
The dispatcher's HTTP arm (commit 5 had a stub) now decodes the
`HttpDispatchPayload` off the outbox row, looks up the script,
synthesizes an `ExecRequest`, and runs it through the executor.
Outcome routing reuses the same path as KV triggers — sync HTTP
flows through the inbox, async dispatch gets dropped after
success (or DL'd on exhaustion).
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>
`/api/v1/admin/apps/{id}/triggers/*` — separate POST endpoints per
kind (kv / dead_letter) so each request validates against the
correct shape. List and DELETE work across both kinds.
Gated on `Capability::AppManageTriggers(app_id)`, which maps onto
`Scope::AppAdmin` (no new scope variants — seven-scope commitment
held) and is granted at the per-app `AppAdmin` role.
Request payloads accept `dispatch_mode` (defaults to `async`) and
retry-override fields. Omitted retry fields fall back to
`TriggerConfig::from_env`, which the binary plumbs into
`TriggersState` so the row is auditable from itself (no lazy
resolution at dispatch time). `registered_by_principal` is taken
from the authenticated principal — design notes §4: "a trigger
execution runs as the principal that registered the trigger".
DELETE loads the trigger first and 404s if its `app_id` doesn't
match the path — prevents a caller with rights on app A from
deleting a trigger via app B's path (bound-key safety net).
In-memory tests cover: app-not-found, member-without-role 403,
default-fallback for retry settings when request omits them,
empty-glob rejection, cross-app delete is treated as not-found.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrations 0008-0011 lay down the triggers framework's storage:
- `triggers` + `kv_trigger_details` + `dead_letter_trigger_details`
(Layout E, design notes §2). Parent table carries common columns
including `registered_by_principal` — the dispatcher uses this to
run the trigger as the user that registered it (design notes §4).
- `outbox`: universal async dispatch substrate. KV/cron/pubsub/queue/
email/dead-letter all write rows in the same shape; the dispatcher
claims due rows via FOR UPDATE SKIP LOCKED. `reply_to` is the
NATS-style inbox id for sync HTTP (commit 6) — its presence flags
"don't retry" per the design.
- `dead_letters`: exact schema from design notes §4 with the four-
value `resolution` CHECK constraint (`replayed | ignored |
handled_by_script | handler_failed`) and partial index on
unresolved rows for the dashboard badge.
- `abandoned_executions`: forensic table for the dispatcher's
"tried to resolve a dropped inbox" edge case (design notes §3 #9).
Repo surfaces with Postgres impls behind traits so unit tests can
swap in-memory backings:
- `TriggerRepo` — CRUD + the `list_matching_kv` /
`list_matching_dead_letter` hot paths the dispatcher uses.
Includes a `collection_matches` helper that handles `*`, `prefix:*`,
and exact-name globs.
- `OutboxRepo` — insert + claim-due + delete + reschedule.
- `DeadLetterRepo` — insert + get + list + unresolved-count +
resolve + GC.
- `AbandonedRepo` — insert + GC.
`TriggerConfig::from_env` (new module) follows the existing
`SandboxCeiling` env-loading pattern for `PICLOUD_MAX_TRIGGER_DEPTH`,
`PICLOUD_TRIGGER_RETRY_*`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, and
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`.
`Capability::AppManageTriggers(AppId)` and `AppDeadLetterManage(AppId)`
join the enum. Both map onto the existing `Scope::AppAdmin` per the
seven-scope commitment; `role_satisfies` grants them at the
`AppAdmin` per-app role.
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>
First v1.1.1 commit. Adds the KV store the design notes commit to:
`(app_id, collection, key)` identity with JSONB value and a per-app
index. Trait lives in `picloud-shared` so the executor-core Rhai
bridge (next commit), the Postgres impl, and tests all depend on the
same surface without coupling crates.
The `Services` bundle grows from empty to three fields: `kv`,
`dead_letters` (NoopDeadLetterService stub — replaced by the
Postgres impl in commit 8), and `events` (NoopEventEmitter until the
outbox emitter lands with the dispatcher). Tests use
`Services::default()` for an all-noop bundle.
New capabilities `AppKvRead` / `AppKvWrite` join the Capability
enum. They map onto the existing seven-value `Scope` (script:read /
script:write) — the scope vocabulary stays locked per the
`docs/versioning.md` commitment.
Script-as-gate semantics in `KvServiceImpl`: capability check runs
when `cx.principal.is_some()`, skipped when None (public HTTP).
Cross-app isolation is enforced independently by deriving every
row's `app_id` from `cx.app_id` rather than a script-passed argument.
In-memory `KvRepo` impl + unit tests cover the round-trips, the
cross-app isolation property, empty-collection rejection,
script-as-gate behaviour for both anonymous and authed contexts,
and cursor-style pagination. Postgres impl exists; integration
testing waits for a real DB harness (see HANDBACK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Annotates the v1.1.x design notes with the resolutions for the 20 open
calls — pub/sub split, universal outbox, NATS-style sync HTTP, status
code strategy, retry policy, dead-letter recursion-stop, realtime
auth model, frontend client library scope. Captured ahead of the
v1.1.1 implementation so the schema + API decisions in this branch
have a single load-bearing source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the architectural conversations that followed the v1.1.0
release but haven't yet landed in the blueprint or in code. Six topic
areas, each with status + open calls:
1. Messaging primitives — invoke vs pub/sub vs queue, recipient
model and delivery semantics
2. Universal trigger outbox — async dispatch substrate for every
event source (sync HTTP excepted, see #3)
3. NATS-style sync HTTP — per-request inbox + oneshot channel lets
sync HTTP ride the outbox without losing the response path
4. Dead-letter handling — separate table, dead_letter trigger kind,
recursion stop rule, retention defaults
5. Realtime updates — SSE-based external subscription to per-app
pub/sub topics with opt-in exposure
6. Frontend client library — hybrid model (TS lib that talks to
dev-defined script endpoints, not to services)
Plus a revised v1.1.x roadmap: realtime adds at v1.1.6 (was Config &
Email), shifting later items by one to v1.1.9 (was v1.1.8).
20 open calls consolidated at the bottom, numbered for reference.
Document is meant to be pruned as decisions ship; deleted entirely
when v1.1.9 lands.
No blueprint changes yet — those wait for the open calls to be
answered and the corresponding PRs to ship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the "When to bump what" section now that the project is
post-1.0. Replaces the pre-1.0 framing with three explicit rules:
- Major: surface major bump on a user-facing contract
- Minor: phase milestone or coherent capability cluster, aligned
with blueprint Phase boundaries (Phase 5 -> v1.2, etc.)
- Patch: bug fixes AND additive-only surface changes
The carve-out (patch for additive surface changes) resolves the
tension with the v1.1.x roadmap: every v1.1.x release adds SDK or
schema surface, and strict "minor product bump per minor surface
bump" would inflate the version faster than the user-perceived
"platform changed" milestones warrant.
Examples updated to reflect post-1.0 numbers and the new policy:
adding KV in v1.1.1 (patch), cutting v1.2 as a phase milestone
(minor), renaming a ctx field (major).
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>
A script author opening docs/stdlib-reference.md should see every
function they can call without imports: the Rhai built-in stdlib (math,
string, array, map, blob) plus the seven new PiCloud namespaces. Tight
tables over prose — scannable rather than exhaustive.
CLAUDE.md current-focus paragraph picks up a pointer to the new doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
43 tests exercising one happy path and the major error paths per
module (invalid regex pattern, oversize random::bytes, malformed JSON,
bad base64, mixed-case hex round-trip, invalid UTF-8 in url::decode,
etc.). Harness duplicates the pattern from sdk_contract.rs — each
integration test file in this crate keeps its own; there is no
tests/common/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the v1.1.0 user-visible stdlib: regex, random, time, json, base64,
hex, url — each exposed as a `::` namespace mirroring the existing
`log::` pattern. Modules register once at engine build via
`Engine::register_static_module`, distinct from the stateful service
modules (KV, docs, …) that hook into `sdk::register_all` per call.
- regex: linear-time, compile-per-call (no cache by design)
- random: OsRng only; bytes/string capped to prevent script-side blow-up
- time: UTC, ms-since-epoch as canonical i64; RFC 3339 strings for I/O
- json: parse/stringify via existing dynamic<->json bridge
- base64: standard + URL-safe alphabets, Blob and String inputs
- hex: lowercase output, case-insensitive decode
- url: RFC 3986 percent-encoding + encode_query for Maps
Stdlib registration runs unconditionally — including in the parse-only
validate path — so scripts get a uniform surface in both phases.
See docs/sdk-shape.md for the stateless-vs-stateful distinction.
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>
v1.1.0 PR #0 — SDK Foundation.
Lands the architectural shape every v1.1.x stateful service hangs off,
without shipping any user-visible service. After this PR, subsequent
service PRs (KV v1.1.1, docs v1.1.2, …) are mechanical fill-in:
- picloud_shared::{SdkCallCx, Services, ServiceEventEmitter +
NoopEventEmitter} lock the per-call context, service bundle,
and event-emission trait shape.
- executor-core::sdk/ — register_all hook called per invocation;
json↔dynamic bridge moved here from engine.rs.
- ExecRequest gained app_id, principal, trigger_depth,
root_execution_id (the last two reserved for v1.1.1's triggers
framework).
- orchestrator-core::gate::ExecutionGate — single global semaphore
(PICLOUD_MAX_CONCURRENT_EXECUTIONS, default 32). Overflow returns
503 + Retry-After: 1 immediately, no queue.
- manager-core::attach_principal_if_present — opportunistic,
fail-open middleware wired on data-plane + user-routes.
- docs/sdk-shape.md — developer-facing reference for the
conventions every future service PR implements against.
- Blueprint revisions: Phase 3.5 marked ✓ Shipped, §8.1 KV switched
from hstore to JSONB, new §7.5 SDK Architecture section and §7.5.1
trigger sketch, §12 Phase 4 restructured into v1.1.0 → v1.1.8.
- CLAUDE.md: current focus → v1.1.0, JSONB note, handle-pattern
Working Rule, Runtime Configuration table with the new env var.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two review-pass nits from the v1.1.0-foundation review:
- Blueprint §6 Tech Stack table still listed the database as
"PostgreSQL + hstore" with an hstore-for-KV rationale — directly
contradicting the §8.1 KV rewrite that explicitly rejected hstore
in favour of JSONB. Updates the row so the high-level summary
matches the §8.1 reasoning.
- LocalExecutorClient::execute now documents the permit-vs-timeout
interaction: when tokio::time::timeout fires the future drops and
the permit returns, but the detached spawn_blocking thread keeps
running until the Rhai script winds down. In-use blocking threads
can briefly exceed the gate's permit count after a timeout. Calling
it out so future readers don't read the implementation as buggy.
No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sdk/bridge.rs: drop #[must_use] on the bridge fns — `Dynamic` and
`serde_json::Value` are both #[must_use] already; the wrapper
attribute is double-must-use noise.
- api.rs IntoResponse: hoist `use ApiError as E;` above the early
Overloaded branch so `E::Exec(...)` works in the if-let too
(clippy::items_after_statements).
- gate.rs test: bind the returned permit with `let _ =` so the
OwnedSemaphorePermit doesn't trip unused-must-use.
No behaviour change. Caught by `cargo clippy --all-targets
--all-features -- -D warnings`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Current focus moves to v1.1.0 (SDK foundation + stdlib) with a
pointer to docs/sdk-shape.md. Notes Phase 3.5 capability gating is
shipped end-to-end.
- Tech-stack line drops the misleading "v1.1+ hstore" mention; v1.1+
data-plane tables now use JSONB (see blueprint §8.1).
- New Working Rules bullet for the handle pattern + SdkCallCx rule:
services derive app_id from cx.app_id, never from a script-passed
arg. That is the cross-app isolation boundary.
- New "Runtime configuration" table documenting every env var the
picloud binary consumes — including the new
PICLOUD_MAX_CONCURRENT_EXECUTIONS alongside the existing
PICLOUD_BIND, DATABASE_URL, session TTL, and sandbox knobs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the developer-facing reference for the SDK shape every v1.1.x
service implements against, plus the blueprint changes the shape and
the recently-shipped Phase 3.5 imply:
- New docs/sdk-shape.md — covers handle pattern, :: namespace,
throw/() error convention, sync↔async bridge, cross-app isolation
rule, ServiceEventEmitter, ExecutionGate + env var, stateless vs
stateful module registration.
- Blueprint §11.6 (Phase 3.5): Pending → ✓ Shipped, with a note that
it landed ahead of the originally planned slot.
- Blueprint §8.1 (KV Store): replace hstore schema + rationale with
JSONB. PK becomes (app_id, collection, key); cross-app isolation
is enforced at the index, not just the service layer. Note 64 KiB
per-value cap enforced at the service layer (lands with the KV PR
in v1.1.1).
- Blueprint new §7.5 (SDK Architecture): brief overview pointing to
docs/sdk-shape.md. Includes §7.5.1 sketch of the trigger
architecture (outbox + depth limit + (service, event, filter) →
script).
- Blueprint §12 Phase 4: restructured to enumerate v1.1.0 through
v1.1.8 with one focused capability per release. Current focus
moves to Phase 4 (v1.1.0) now that Phase 3.5 is done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The data-plane (POST /execute/{id} + user-route fallback) is
unauthenticated by default — public scripts get hit by anonymous HTTP
traffic. But some calls are authed (dashboard test-runs, API-key
invocations) and v1.1.x services will want to see the caller via
`cx.principal` for audit / authz once those features land.
- New manager-core::attach_principal_if_present middleware. Always
inserts Extension<Option<Principal>>: Some on resolved bearer/cookie,
None on absent or malformed token. Fail-open on DB blip so a
transient infra failure can't 500 anonymous traffic.
- Wired in picloud build_app, scoped to the data-plane and user-routes
routers only. The admin path keeps using require_authenticated; no
double-resolve on the same token.
- orchestrator-core handlers (execute_by_id, user_route_handler) now
extract Extension<Option<Principal>> and pass it to build_exec_request.
Replaces the temporary `None` placeholders from the previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single global concurrency cap on the data-plane dispatch path:
- orchestrator-core::gate::ExecutionGate wraps tokio::Semaphore.
Non-blocking try_acquire — no queue. PICLOUD_MAX_CONCURRENT_EXECUTIONS
env var (default 32) sets the cap.
- LocalExecutorClient acquires a permit before spawn_blocking; the
permit drops with the future so the slot returns automatically.
- On refusal, ExecError::Overloaded { retry_after_secs: 1 } surfaces
upward. ApiError::IntoResponse already maps that to 503 with a
Retry-After header (landed in the previous commit alongside the
variant itself).
- picloud binary constructs the gate once at build_app and shares it
with LocalExecutorClient.
The cap exists so a Rhai script storm can't drain the blocking-thread
pool — pushing back hard beats letting requests pile up against a
finite worker count. Per-app / per-script caps stay deferred until a
real workload demands them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the four internal-only fields every v1.1.x stateful service needs
to isolate by app and audit by caller:
- app_id — owning app for this invocation
- principal — Option<Principal>; data-plane is unauthenticated
today so the orchestrator passes None until the
opportunistic middleware lands in the next commit
- trigger_depth — 0 for direct invocations; the triggers framework
(v1.1.1) bounds runaway feedback loops via this
- root_execution_id — equal to execution_id for direct invocations;
preserved across trigger fan-out for audit grouping
ExecRequest stays serializable (cluster mode still has to ship it across
processes when v1.3+ arrives). principal is `#[serde(skip)]` because
shared::Principal has no wire derivation today — when cluster mode lands
the wire-Principal question gets revisited properly.
Engine now carries a Services bundle (empty in v1.1.0). Engine::execute
constructs an SdkCallCx from the request and hands it to sdk::register_all
just after the per-call Rhai engine is built. The hook is a no-op in v1.1.0;
v1.1.1 KV registers its first native fns there.
Adds ExecError::Overloaded { retry_after_secs } and the matching 503 +
Retry-After mapping in orchestrator-core's IntoResponse. The gate that
actually produces this variant lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoist the json_to_dynamic / dynamic_to_json helpers out of engine.rs
into a new sdk/bridge.rs so the v1.1.x service modules (KV, docs, …)
can use them without engine.rs being the sole owner. No behavioural
change — the sdk_contract round-trip test pins the observable JSON
fidelity.
Also lands the structural shape that subsequent v1.1.x PRs hook into:
- sdk::register_all(engine, services, cx) — single per-call hook
every stateful service registers through. Body is a no-op for
v1.1.0; SdkCallCx construction inside Engine::execute lands in
the next commit alongside the new ExecRequest fields it reads.
- sdk::cx re-exports picloud_shared::SdkCallCx so SDK callers don't
cross-import shared for one type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the v1.1.x stateful SDK services. Lands the shape only:
- SdkCallCx — per-call context plumbed into every future service
trait method (app_id, principal, execution/request ids, trigger
depth slots).
- Services — empty non_exhaustive bundle; v1.1.1 (KV) adds the first
field, subsequent PRs follow.
- ServiceEventEmitter — async trait future services emit through;
real outbox-backed impl lands with triggers in v1.1.1. NoopEventEmitter
is the v1.1.0 default.
No behaviour change. Subsequent commits in this PR plumb these types
through executor-core and the orchestrator dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors the bare-metal CLI e2e into focused journey modules sharing
one `LazyLock<Fixture>` server (mirrors the dashboard Playwright
suite's spawn-once shape), and folds in the comprehensive review-pass
fixes on top:
* `pic login` is now real auth — username + password POST'd to
`/auth/login`. `--token` / `PICLOUD_TOKEN` keep the paste-a-bearer
path for CI and API keys.
* `pic logout`, `pic apps delete|show`, `pic scripts delete`,
`pic api-keys mint|ls|rm`, top-level `pic invoke` / `pic deploy`.
* `PICLOUD_URL` / `PICLOUD_TOKEN` override the on-disk creds file
globally (gcloud/aws semantics), not just for `pic login`.
* Global `--output tsv|json` flag.
* `pic scripts ls` (no `--app`) collapses the N+1 per-app walk that
aborted on the first 404 into a single `GET /admin/scripts` plus
one parallel `apps_list`. Drops the 5× retry the test suite was
carrying around it.
* HTTP-4xx asserts tightened to specific codes (422/404/403). The
old loose `"HTTP 4"` predicates would have masked a regressed 401
from broken auth.
* Redundant `tests/integration.rs` deleted — every step it covered
lives in one of the focused modules.
All endpoints touched on the server side already existed before this
branch — no `manager-core` change here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the `pic` command-line client: `pic login | whoami | apps
ls/create | scripts ls/deploy/invoke | logs`. Thin wrapper over the
existing admin + execute HTTP surface — no new server endpoints
introduced by this branch.
See `crates/picloud-cli/` for the binary and its bare-metal e2e
test. The follow-up `test/cli-journeys` branch refactors that test
into focused journey modules and extends the CLI with login/logout,
delete commands, api-keys, and JSON output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single bare-metal `integration.rs` test with focused
modules driven by the shared `LazyLock<Fixture>` server. Each module
owns one journey:
* `auth.rs` — login (both bearer and username+password paths),
logout (local file + server-side session invalidation), env-vars
overriding the on-disk credentials file, role-label rendering.
* `apps.rs` — create / ls / show / delete (with and without
`--force`), invalid-slug rejection, conflict on duplicate slug.
* `scripts.rs` — deploy (create + update), name override, version
bumping, `ls` (with and without `--app`), delete.
* `invoke.rs` — body sources (inline, `@file`, `@-`), header
propagation, non-2xx exit semantics, top-level `pic invoke` alias.
* `logs.rs` — emptiness, status labels, `--limit`, summary truncation.
* `roles.rs` — Member RBAC: app-list filtering, viewer-vs-editor on
deploy, member can hit the unguarded data plane, non-member 403
on logs.
* `output.rs` — TSV column headers, stdout/stderr separation, RFC3339
shape, and the `--output json` invariants for apps / scripts /
logs / whoami.
* `api_keys.rs` — mint emits `raw_token` once, `ls` omits it, the
minted token works as a real bearer, `rm` invalidates server-side.
Bug-bug-fix-bug-fix:
* The 5× retry loop in `ls_without_app_walks_every_accessible_app`
was masking the abort-on-first-404 walk in the CLI. Now that the
CLI uses a single server call, the retry is gone — the test runs
one `pic scripts ls` and asserts.
* Six `predicate::str::contains("HTTP 4")` assertions tightened to
the specific status code: 422 for invalid-slug, 404 for unknown
app/script/log id, 403 for role denials. Loose `HTTP 4` would
have silently matched a regressed 401 from broken auth.
* `tests/integration.rs` deleted — every step it covered is in one
of the focused modules above.
* Members module exposes `MEMBER_PASSWORD` so auth tests can drive
the real username+password flow over stdin.
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>
A trailing fmt drift on tests/cli.rs:95 — `format!()` arg was wrapped
across three lines where rustfmt wants one. Running `cargo fmt --all`
collapses it; no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spawns the pre-built `picloud` binary against DATABASE_URL on a
private port, logs in over HTTP to mint a bearer token, then drives
`pic` through the full edit-deploy-invoke-tail loop with a unique
app slug per run and a `Drop`-based cleanup. Gated on DATABASE_URL
and tagged `#[ignore]` to match the existing integration-test
pattern in `crates/picloud/tests/api.rs`.
The test uses the dev `admin/admin` credentials (overridable via
PICLOUD_CLI_E2E_USERNAME / _PASSWORD) because the bootstrap env
vars are inert once the DB has any admin row.
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>
The test creates a script in the default app earlier in the body, so a
plain DELETE /apps/default hits the soft no-cascade guard and 409s
before the capability check runs. The intent is to validate that admin
holds AppAdmin everywhere, not to exercise the cascade contract — pass
?force=true so we reach the gate we're trying to test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CleanupRegistry's catch-all was masking every kind of teardown error,
not just the intended "resource already gone" 404. A backend returning
500 on delete would leak orphans run after run without ever surfacing.
Now treat 2xx and 404 as success, log any other status (and any
thrown network error) to stderr with the resource label, and keep
running the remaining items. The suite stays best-effort but no
longer hides accumulating leaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Other services in the prod overlay already have it. Without it, a
`docker compose stop caddy` followed by `docker compose up -d` doesn't
bring caddy back up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Date.now() can collide across workers running on the same millisecond
boundary. The worker-aware helper that the rest of the suite uses
side-steps that without changing the test's intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Array.reverse mutates in place — a defensive double-run() would have
re-reversed the items. Iterate over a copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cancels once to assert the modal can be dismissed without side
effects, then confirms to flip the user to inactive, then reactivates
to assert that direction remains one-click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into
fixtures/role-page.ts (third file that needs them). Adds shadowing
coverage: viewer member sees no New-app / Add-domain / Settings / Save
/ +Add-route, editor sees Save but no Delete header, and CodeMirror
renders contenteditable=false for viewers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes e2e-* apps and e2e* admin users before the suite starts so a
prior crashed run doesn't accumulate state across runs (45 rows
observed on 2026-05-28). Per-row try/catch keeps it best-effort; a
sweep failure never blocks the suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches to Uint8 rejection sampling against the largest multiple of
the charset length that fits in a byte. Eliminates the ~16 ppm
overweight the previous `% N` over Uint32 would otherwise leave on the
first 38 chars. Adds a vitest distribution check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces window.confirm + alert() with the in-dashboard ConfirmModal
(danger variant, name-retype). Body summarises what gets removed
(routes + execution logs) and embeds the API error inline rather than
firing a native alert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-function module that mirrors crates/manager-core/src/authz.rs and
lets dashboard pages decide which create / edit / delete affordances to
render. Widens the vitest include so the truth-table test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>