Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion crates/tracedecay-dashboard-api/src/automation_run_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,15 @@ pub async fn run_list(
}

/// One ledger record as the run-history row: identity, outcome, review tallies,
/// and which artifacts exist — every field measured from the record itself.
/// typed failure class and backend attempt count, and which artifacts exist —
/// every field measured from the record itself. `task_key` is the exact
/// per-job identity (`user_job:<id>`) the ledger writer recorded; rows written
/// before it existed carry `null` and cannot be joined to a job.
fn run_history_row(record: &AutomationRunLedgerRecord) -> Value {
json!({
"run_id": record.run_id,
"task": record.task,
"task_key": record.task_key,
"trigger": record.trigger,
"backend": record.backend,
"model": record.model,
Expand All @@ -76,6 +80,9 @@ fn run_history_row(record: &AutomationRunLedgerRecord) -> Value {
"rejected_count": record.rejected_count,
"skipped_count": record.skipped_count,
"error": record.error,
"error_classification": record.error_classification,
"error_retryable": record.error_retryable,
"backend_attempt_count": record.backend_attempt_count,
"started_at": record.started_at,
"completed_at": record.completed_at,
"artifact_kinds": record
Expand Down Expand Up @@ -218,3 +225,63 @@ fn expected_artifact_chain_kinds() -> Vec<&'static str> {
AutomationRunArtifactKind::CodexHandoff.as_str(),
]
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

fn record(value: Value) -> AutomationRunLedgerRecord {
serde_json::from_value(value).unwrap()
}

#[test]
fn run_history_row_carries_job_identity_and_typed_failure_fields() {
let row = run_history_row(&record(json!({
"schema_version": 2,
"run_id": "dashboard_user_job_nightly_1",
"trigger": "scheduler",
"task": "user_job",
"task_key": "user_job:nightly",
"backend": "codex_app_server",
"status": "failed",
"accepted_count": 0,
"rejected_count": 0,
"error": "provider lease expired",
"error_classification": "retryable",
"error_retryable": true,
"backend_attempt_count": 2,
"started_at": "1754000000",
"completed_at": "1754000031",
})));

assert_eq!(row["task_key"], "user_job:nightly");
assert_eq!(row["error_classification"], "retryable");
assert_eq!(row["error_retryable"], true);
assert_eq!(row["backend_attempt_count"], 2);
assert_eq!(row["artifact_kinds"], json!([]));
}

#[test]
fn run_history_row_keeps_absent_identity_and_failure_fields_null() {
let row = run_history_row(&record(json!({
"schema_version": 2,
"run_id": "legacy_run",
"trigger": "manual_cli",
"task": "memory_curator",
"backend": "claude",
"status": "succeeded",
"accepted_count": 1,
"rejected_count": 0,
"started_at": "1754000000",
"completed_at": "1754000060",
})));

// A pre-`task_key` row must not be joined to any job, and an absent
// failure classification is an absence rather than a default class.
assert_eq!(row["task_key"], Value::Null);
assert_eq!(row["error_classification"], Value::Null);
assert_eq!(row["error_retryable"], Value::Null);
assert_eq!(row["backend_attempt_count"], 0);
}
}
Binary file modified dashboard/audit-baselines/automations__dark__1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/audit-baselines/automations__dark__320.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/audit-baselines/automations__dark__768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/audit-baselines/automations__light__1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/audit-baselines/automations__light__320.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified dashboard/audit-baselines/automations__light__768.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 63 additions & 2 deletions dashboard/src/data/query/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ export function useSchedulerControl() {
* a wire contract is what the daemon sends, and a surface that owned its own
* copy of one would be the second authority on a shape it does not serve.
*/
/** `JobDelivery` (jobs.rs) is `#[serde(tag = "mode")]` over `file` and
* `webhook`. The mode stays an open string so a delivery mode added to the
* daemon renders as its own word rather than failing the whole jobs read. */
const JobDeliverySchema = z
.object({
mode: z.string(),
path: z.string().nullable().optional(),
url: z.string().nullable().optional(),
})
.passthrough();

/** `AutomationJob` (jobs.rs). `schedule`, `interval_secs`, `cooldown_secs`,
* `skill_ids` and `pre_run_command` are `skip_serializing_if` on the struct,
* so their absence on the wire is the daemon saying "not set", not a
* truncated body. */
const JobsPayloadSchema = z
.object({
jobs: z.array(
Expand All @@ -212,6 +227,12 @@ const JobsPayloadSchema = z
schedule: z.string().nullable().optional(),
enabled: z.boolean(),
interval_secs: z.number().nullable().optional(),
cooldown_secs: z.number().nullable().optional(),
skill_ids: z.array(z.string()).optional(),
pre_run_command: z.string().nullable().optional(),
delivery: JobDeliverySchema.optional(),
created_at: z.number().optional(),
updated_at: z.number().optional(),
})
.passthrough(),
),
Expand All @@ -226,6 +247,17 @@ const JobsPayloadSchema = z
* and the last of which printed an array index as if it were a skill. */
const ManagedSkillStateSchema = z.enum(["active", "disabled", "archived"]);

/** `ManagedSkillProvenance`: which authority wrote the skill. `source` is
* `automation_run`, `user`, or `import` today; kept open so a new source
* renders as its own word. */
const ManagedSkillProvenanceSchema = z
.object({
source: z.string(),
actor: z.string(),
run_id: z.string().nullable().optional(),
})
.passthrough();

const SkillsPayloadSchema = z
.object({
skills: z.array(
Expand All @@ -236,6 +268,11 @@ const SkillsPayloadSchema = z
id: z.string(),
title: z.string(),
state: ManagedSkillStateSchema,
category: z.string().optional(),
targets: z.array(z.string()).optional(),
updated_at: z.number().optional(),
activated_at: z.number().nullable().optional(),
provenance: ManagedSkillProvenanceSchema.optional(),
})
.passthrough(),
})
Expand Down Expand Up @@ -309,15 +346,18 @@ export function useAutomationFactReceipts() {

/** `automation_run_api::run_list` (`/api/automation/runs`): the newest ledger
* records, projected by `run_history_row`. Every payload key below is
* unconditional; `model` and `error` are nullable because the writer emits
* null when absent. */
* unconditional; `model`, `error`, `task_key`, `error_classification` and
* `error_retryable` are nullable because the writer emits null when the
* record never carried them. `task_key` is the exact per-job identity
* (`user_job:<id>`) and is the only field a user job may be joined on. */
const RunsPayloadSchema = z
.object({
runs: z.array(
z
.object({
run_id: z.string(),
task: z.string(),
task_key: z.string().nullable(),
trigger: z.string(),
backend: z.string(),
model: z.string().nullable(),
Expand All @@ -327,6 +367,9 @@ const RunsPayloadSchema = z
rejected_count: z.number(),
skipped_count: z.number(),
error: z.string().nullable(),
error_classification: z.string().nullable(),
error_retryable: z.boolean().nullable(),
backend_attempt_count: z.number().int().nonnegative(),
started_at: z.string(),
completed_at: z.string(),
artifact_kinds: z.array(z.string()),
Expand Down Expand Up @@ -396,6 +439,24 @@ const RunArtifactPayloadSchema = z

export type RunRow = z.infer<typeof RunsPayloadSchema>["runs"][number];
export type RunsPayload = z.infer<typeof RunsPayloadSchema>;

/** `AutomationTaskStatusV1.last_scheduler_run` is the most recent
* scheduler-triggered `AutomationRunLedgerRecord`, typed `unknown` in the
* generated contract because the record is the ledger's own shape rather than
* a dashboard DTO. This reads the identity and outcome the scheduler bay
* needs; the run's artifacts remain behind the run ledger and its routes. */
export const SchedulerLastRunSchema = z
.object({
run_id: z.string(),
status: z.string(),
started_at: z.string(),
completed_at: z.string(),
error: z.string().nullable().optional(),
error_classification: z.string().nullable().optional(),
error_retryable: z.boolean().nullable().optional(),
})
.passthrough();
export type SchedulerLastRun = z.infer<typeof SchedulerLastRunSchema>;
export type RunArtifactsPayload = z.infer<typeof RunArtifactsPayloadSchema>;
export type RunArtifactRow = RunArtifactsPayload["artifacts"][number];
export type RunArtifactPayload = z.infer<typeof RunArtifactPayloadSchema>;
Expand Down
Loading