refactor(api): align run projection types

This commit is contained in:
Bryan Helmkamp 2026-04-29 20:32:50 -04:00
parent 3e97cae0ae
commit 6a8329b376
No known key found for this signature in database
13 changed files with 383 additions and 6 deletions

View file

@ -4946,12 +4946,56 @@ components:
stderr:
type: ["string", "null"]
InterviewOption:
description: Option stored with an interview question in the event log.
type: object
required:
- key
- label
properties:
key:
type: string
label:
type: string
InterviewQuestionRecord:
description: Storage shape of an interview question recorded in the event log.
type: object
required:
- id
- text
- stage
- question_type
- allow_freeform
properties:
id:
type: string
text:
type: string
stage:
type: string
question_type:
$ref: "#/components/schemas/QuestionType"
options:
type: array
items:
$ref: "#/components/schemas/InterviewOption"
allow_freeform:
type: boolean
timeout_seconds:
type: ["number", "null"]
format: double
context_display:
type: ["string", "null"]
PendingInterviewRecord:
description: Pending interview question plus the time it entered the unresolved set.
type: object
required:
- question
properties:
question:
$ref: "#/components/schemas/ApiQuestion"
$ref: "#/components/schemas/InterviewQuestionRecord"
started_at:
type: ["string", "null"]
format: date-time
@ -5041,6 +5085,10 @@ components:
- type: string
format: date-time
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
- type: "null"
checkpoint:
oneOf:
- $ref: "#/components/schemas/RunCheckpoint"

View file

@ -316,6 +316,18 @@ fn main() {
("InternalStageStatus", "fabro_types::StageStatus", &[]),
("NodeState", "fabro_types::NodeState", &[]),
("SecretMetadata", "fabro_types::SecretMetadata", &[]),
("InterviewOption", "fabro_types::InterviewOption", &[]),
(
"InterviewQuestionRecord",
"fabro_types::InterviewQuestionRecord",
&[],
),
(
"PendingInterviewRecord",
"fabro_types::PendingInterviewRecord",
&[],
),
("RunProjection", "fabro_types::RunProjection", &[]),
("PullRequestRecord", "fabro_types::PullRequestRecord", &[]),
("PullRequestDetail", "fabro_types::PullRequestDetail", &[]),
("PullRequestUser", "fabro_types::PullRequestUser", &[]),

View file

@ -28,9 +28,11 @@ pub mod types {
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};
pub use fabro_types::{
ActorKind, ActorRef, DiffStats, DirtyStatus, GitContext, NodeState, NodeStatusRecord,
PreRunPushOutcome, QuestionType, RepositoryReference, RunSummary, SecretMetadata,
SecretType, ServerSettings, StageStatus as InternalStageStatus, WorkflowSettings,
ActorKind, ActorRef, DiffStats, DirtyStatus, GitContext, InterviewOption,
InterviewQuestionRecord, NodeState, NodeStatusRecord, PendingInterviewRecord,
PreRunPushOutcome, QuestionType, RepositoryReference, RunProjection, RunSummary,
SecretMetadata, SecretType, ServerSettings, StageStatus as InternalStageStatus,
WorkflowSettings,
};
pub use crate::generated::types::*;

View file

@ -0,0 +1,31 @@
use std::any::{TypeId, type_name};
use fabro_api::types::InterviewOption as ApiInterviewOption;
use fabro_types::InterviewOption;
use serde_json::json;
#[test]
fn interview_option_reuses_canonical_type() {
assert_same_type::<ApiInterviewOption, InterviewOption>();
}
#[test]
fn interview_option_round_trips_representative_json() {
let value = json!({
"key": "approve",
"label": "Approve"
});
let option: InterviewOption = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(option).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -0,0 +1,40 @@
use std::any::{TypeId, type_name};
use fabro_api::types::InterviewQuestionRecord as ApiInterviewQuestionRecord;
use fabro_types::InterviewQuestionRecord;
use serde_json::json;
#[test]
fn interview_question_record_reuses_canonical_type() {
assert_same_type::<ApiInterviewQuestionRecord, InterviewQuestionRecord>();
}
#[test]
fn interview_question_record_round_trips_representative_json() {
let value = json!({
"id": "q-1",
"text": "Approve deploy?",
"stage": "gate",
"question_type": "multiple_choice",
"options": [
{ "key": "approve", "label": "Approve" },
{ "key": "reject", "label": "Reject" }
],
"allow_freeform": true,
"timeout_seconds": 30.0,
"context_display": "Diff summary"
});
let question: InterviewQuestionRecord = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(question).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -0,0 +1,60 @@
use std::any::{TypeId, type_name};
use fabro_api::types::PendingInterviewRecord as ApiPendingInterviewRecord;
use fabro_types::PendingInterviewRecord;
use serde_json::json;
#[test]
fn pending_interview_record_reuses_canonical_type() {
assert_same_type::<ApiPendingInterviewRecord, PendingInterviewRecord>();
}
#[test]
fn pending_interview_record_round_trips_populated_question() {
let value = json!({
"question": {
"id": "q-1",
"text": "Approve deploy?",
"stage": "gate",
"question_type": "multiple_choice",
"options": [
{ "key": "approve", "label": "Approve" },
{ "key": "reject", "label": "Reject" }
],
"allow_freeform": true,
"timeout_seconds": 30.0,
"context_display": "Diff summary"
},
"started_at": "2026-04-29T12:34:56Z"
});
let record: PendingInterviewRecord = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(record).unwrap(), value);
}
#[test]
fn pending_interview_record_allows_empty_options_to_be_omitted() {
let value = json!({
"question": {
"id": "q-2",
"text": "Any notes?",
"stage": "notes",
"question_type": "freeform",
"allow_freeform": true
},
"started_at": null
});
let record: PendingInterviewRecord = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(record).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -0,0 +1,114 @@
use std::any::{TypeId, type_name};
use fabro_api::types::RunProjection as ApiRunProjection;
use fabro_types::RunProjection;
use serde_json::json;
#[test]
fn run_projection_reuses_canonical_type() {
assert_same_type::<ApiRunProjection, RunProjection>();
}
#[test]
fn run_projection_round_trips_populated_projection() {
let value = json!({
"spec": null,
"graph_source": null,
"start": null,
"status": null,
"status_updated_at": null,
"pending_control": "pause",
"checkpoint": null,
"checkpoints": [[
7,
{
"timestamp": "2026-04-29T12:34:56Z",
"current_node": "build",
"completed_nodes": ["build"],
"node_retries": {},
"context_values": {},
"node_visits": { "build": 2 }
}
]],
"conclusion": null,
"retro": null,
"retro_prompt": null,
"retro_response": null,
"sandbox": null,
"final_patch": null,
"pull_request": null,
"superseded_by": null,
"pending_interviews": {
"q-1": {
"question": {
"id": "q-1",
"text": "Approve deploy?",
"stage": "gate",
"question_type": "multiple_choice",
"options": [
{ "key": "approve", "label": "Approve" },
{ "key": "reject", "label": "Reject" }
],
"allow_freeform": true,
"timeout_seconds": 30.0,
"context_display": "Diff summary"
},
"started_at": "2026-04-29T12:35:00Z"
}
},
"nodes": {
"build@2": {
"prompt": null,
"response": null,
"status": null,
"provider_used": null,
"diff": "diff --git a/file b/file",
"script_invocation": null,
"script_timing": null,
"parallel_results": null,
"stdout": "done",
"stderr": null
}
}
});
let projection: RunProjection = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(projection).unwrap(), value);
}
#[test]
fn run_projection_round_trips_with_pending_control_unset() {
let value = json!({
"spec": null,
"graph_source": null,
"start": null,
"status": null,
"status_updated_at": null,
"pending_control": null,
"checkpoint": null,
"checkpoints": [],
"conclusion": null,
"retro": null,
"retro_prompt": null,
"retro_response": null,
"sandbox": null,
"final_patch": null,
"pull_request": null,
"superseded_by": null,
"pending_interviews": {},
"nodes": {}
});
let projection: RunProjection = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(projection).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -107,6 +107,8 @@ models/install-server-config-input.ts
models/install-session-response.ts
models/integration-webhooks-settings.ts
models/internal-stage-status.ts
models/interview-option.ts
models/interview-question-record.ts
models/ip-allow-entry.ts
models/literal-ip-allow-entry.ts
models/log-destination.ts

View file

@ -86,6 +86,8 @@ export * from './install-server-config-input';
export * from './install-session-response';
export * from './integration-webhooks-settings';
export * from './internal-stage-status';
export * from './interview-option';
export * from './interview-question-record';
export * from './ip-allow-entry';
export * from './literal-ip-allow-entry';
export * from './log-destination';

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Option stored with an interview question in the event log.
*/
export interface InterviewOption {
'key': string;
'label': string;
}

View file

@ -0,0 +1,38 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { InterviewOption } from './interview-option';
// May contain unused imports in some cases
// @ts-ignore
import type { QuestionType } from './question-type';
/**
* Storage shape of an interview question recorded in the event log.
*/
export interface InterviewQuestionRecord {
'id': string;
'text': string;
'stage': string;
'question_type': QuestionType;
'options'?: Array<InterviewOption>;
'allow_freeform': boolean;
'timeout_seconds'?: number | null;
'context_display'?: string | null;
}

View file

@ -15,13 +15,13 @@
// May contain unused imports in some cases
// @ts-ignore
import type { ApiQuestion } from './api-question';
import type { InterviewQuestionRecord } from './interview-question-record';
/**
* Pending interview question plus the time it entered the unresolved set.
*/
export interface PendingInterviewRecord {
'question'?: ApiQuestion;
'question': InterviewQuestionRecord;
'started_at'?: string | null;
}

View file

@ -24,6 +24,9 @@ import type { PendingInterviewRecord } from './pending-interview-record';
import type { RunCheckpoint } from './run-checkpoint';
// May contain unused imports in some cases
// @ts-ignore
import type { RunControlAction } from './run-control-action';
// May contain unused imports in some cases
// @ts-ignore
import type { RunProjectionCheckpointsInnerInner } from './run-projection-checkpoints-inner-inner';
// May contain unused imports in some cases
// @ts-ignore
@ -41,6 +44,7 @@ export interface RunProjection {
'start'?: { [key: string]: any; } | null;
'status'?: RunStatus | null;
'status_updated_at'?: string | null;
'pending_control'?: RunControlAction | null;
'checkpoint'?: RunCheckpoint | null;
/**
* Sequence-tagged checkpoint history entries as `[seq, checkpoint]`.