Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
88 lines
2.8 KiB
Rust
88 lines
2.8 KiB
Rust
//! `pic logs <script-id>` — print recent execution log rows.
|
|
//!
|
|
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
|
|
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
|
|
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
|
|
//! letting `jq` consumers see request/response bodies in full.
|
|
|
|
use anyhow::Result;
|
|
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
|
|
|
use crate::client::Client;
|
|
use crate::config;
|
|
use crate::output::{OutputMode, Table};
|
|
|
|
pub async fn run(
|
|
script_id: &str,
|
|
limit: u32,
|
|
source: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let entries = client.logs_list(script_id, limit, source).await?;
|
|
match mode {
|
|
OutputMode::Tsv => render_tsv(&entries),
|
|
OutputMode::Json => render_json(&entries),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_tsv(entries: &[ExecutionLog]) {
|
|
// `source` is shown so background runs (queue/cron/invoke/…) are
|
|
// distinguishable from HTTP at a glance — the whole point of G1.
|
|
let mut table = Table::new(["created_at", "source", "status", "summary"]);
|
|
for e in entries {
|
|
let summary = summarize(&e.response_body, &e.script_logs);
|
|
table.row([
|
|
e.created_at.to_rfc3339(),
|
|
e.source.as_str().to_string(),
|
|
status_label(&e.status).to_string(),
|
|
truncate(&summary, 120),
|
|
]);
|
|
}
|
|
table.print(OutputMode::Tsv);
|
|
}
|
|
|
|
fn render_json(entries: &[ExecutionLog]) {
|
|
// Pretty for human jq-piping; consumers that want compact can pipe
|
|
// through `jq -c`.
|
|
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{s}");
|
|
}
|
|
|
|
fn status_label(s: &ExecutionStatus) -> &'static str {
|
|
match s {
|
|
ExecutionStatus::Success => "success",
|
|
ExecutionStatus::Error => "error",
|
|
ExecutionStatus::Timeout => "timeout",
|
|
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
|
}
|
|
}
|
|
|
|
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
|
// Prefer the last script-side log line (often the most useful for
|
|
// grepping). Fall back to the response body.
|
|
if let Some(arr) = script_logs.as_array() {
|
|
if let Some(last) = arr.last() {
|
|
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
|
return msg.to_string();
|
|
}
|
|
}
|
|
}
|
|
response_body
|
|
.as_ref()
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| "-".to_string())
|
|
}
|
|
|
|
fn truncate(s: &str, n: usize) -> String {
|
|
let normalized = s.replace('\n', " ");
|
|
if normalized.chars().count() <= n {
|
|
normalized
|
|
} else {
|
|
let head: String = normalized.chars().take(n).collect();
|
|
format!("{head}…")
|
|
}
|
|
}
|