Merge remote-tracking branch 'origin/develop' into work/T-689-web-subject-create
This commit is contained in:
@@ -819,7 +819,8 @@ mod tests {
|
|||||||
"profiles": [{
|
"profiles": [{
|
||||||
"id": "builtin:default",
|
"id": "builtin:default",
|
||||||
"label": "Default",
|
"label": "Default",
|
||||||
"description": ""
|
"description": "",
|
||||||
|
"feature_connections": { "subjektiv": false }
|
||||||
}],
|
}],
|
||||||
"repositories": [],
|
"repositories": [],
|
||||||
"working_directories": [],
|
"working_directories": [],
|
||||||
@@ -883,6 +884,7 @@ mod tests {
|
|||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
initial_submit: Vec::new(),
|
initial_submit: Vec::new(),
|
||||||
workdir_attachments: Vec::new(),
|
workdir_attachments: Vec::new(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
control_operation_id: None,
|
control_operation_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
content: format!("Please handle intake for Ticket {ticket_id}."),
|
content: format!("Please handle intake for Ticket {ticket_id}."),
|
||||||
}],
|
}],
|
||||||
workdir_attachments: Vec::new(),
|
workdir_attachments: Vec::new(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
control_operation_id: None,
|
control_operation_id: None,
|
||||||
};
|
};
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ pub struct WorkerStateSnapshot {
|
|||||||
schemars(range(min = 0, max = 9_007_199_254_740_991_u64))
|
schemars(range(min = 0, max = 9_007_199_254_740_991_u64))
|
||||||
)]
|
)]
|
||||||
pub last_command_id: u64,
|
pub last_command_id: u64,
|
||||||
|
/// Most recent Submit request whose controller Run has returned and whose
|
||||||
|
/// post-Run state transition has been published. This is a teardown fence,
|
||||||
|
/// not a successful-result claim; cancelled and failed Runs may also finish.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_finished_submission_request_id: Option<String>,
|
||||||
pub state: WorkerState,
|
pub state: WorkerState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +187,7 @@ impl WorkerStateSnapshot {
|
|||||||
pub fn initial() -> Self {
|
pub fn initial() -> Self {
|
||||||
Self {
|
Self {
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state: WorkerState::Idle,
|
state: WorkerState::Idle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +215,7 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state,
|
state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,10 +1044,11 @@ pub enum SessionContentPart {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
pub struct SessionToolAttachment {
|
pub struct SessionToolAttachment {
|
||||||
|
/// Stable, bounded identity derived from the owning Session entry, attachment
|
||||||
|
/// position, and immutable image bytes. It is a locator, not a bearer secret.
|
||||||
|
pub attachment_id: String,
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
pub byte_len: u64,
|
||||||
/// committed multimodal value instead of replacing it with placeholder text.
|
|
||||||
pub data_base64: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -1925,6 +1933,7 @@ mod tests {
|
|||||||
fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
|
fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
|
||||||
let snapshot = WorkerStateSnapshot {
|
let snapshot = WorkerStateSnapshot {
|
||||||
last_command_id: 7,
|
last_command_id: 7,
|
||||||
|
last_finished_submission_request_id: Some("request-7".to_string()),
|
||||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
};
|
};
|
||||||
let value = serde_json::to_value(&snapshot).unwrap();
|
let value = serde_json::to_value(&snapshot).unwrap();
|
||||||
@@ -1932,6 +1941,7 @@ mod tests {
|
|||||||
value,
|
value,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"last_command_id": 7,
|
"last_command_id": 7,
|
||||||
|
"last_finished_submission_request_id": "request-7",
|
||||||
"state": {
|
"state": {
|
||||||
"kind": "busy",
|
"kind": "busy",
|
||||||
"state": { "kind": "run", "state": "running" }
|
"state": { "kind": "run", "state": "running" }
|
||||||
@@ -2648,6 +2658,7 @@ mod tests {
|
|||||||
let event = Event::WorkerState {
|
let event = Event::WorkerState {
|
||||||
snapshot: WorkerStateSnapshot {
|
snapshot: WorkerStateSnapshot {
|
||||||
last_command_id: 9,
|
last_command_id: 9,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2669,6 +2680,7 @@ mod tests {
|
|||||||
snapshot: WorkerStateSnapshot {
|
snapshot: WorkerStateSnapshot {
|
||||||
last_command_id: 9,
|
last_command_id: 9,
|
||||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
|
..
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1085,6 +1085,8 @@ pub const RUNTIME_ROUTE_WORKER_PROTOCOL_WS: &str = "/v1/workers/{worker_id}/prot
|
|||||||
pub const RUNTIME_ROUTE_WORKER_ATTACHMENTS: &str = "/v1/workers/{worker_id}/attachments";
|
pub const RUNTIME_ROUTE_WORKER_ATTACHMENTS: &str = "/v1/workers/{worker_id}/attachments";
|
||||||
pub const RUNTIME_ROUTE_WORKER_ATTACHMENT: &str =
|
pub const RUNTIME_ROUTE_WORKER_ATTACHMENT: &str =
|
||||||
"/v1/workers/{worker_id}/attachments/{artifact_id}";
|
"/v1/workers/{worker_id}/attachments/{artifact_id}";
|
||||||
|
pub const RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT: &str =
|
||||||
|
"/v1/workers/{worker_id}/sessions/{session_id}/attachments/{attachment_id}";
|
||||||
|
|
||||||
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
||||||
/// than letting them be mistaken for management-contract omissions.
|
/// than letting them be mistaken for management-contract omissions.
|
||||||
@@ -1189,6 +1191,11 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
|||||||
path: RUNTIME_ROUTE_WORKER_ATTACHMENT,
|
path: RUNTIME_ROUTE_WORKER_ATTACHMENT,
|
||||||
reason: "Binary attachment transport",
|
reason: "Binary attachment transport",
|
||||||
},
|
},
|
||||||
|
RemainingRuntimeRoute {
|
||||||
|
method: "GET",
|
||||||
|
path: RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT,
|
||||||
|
reason: "Session image attachment transport",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1478,7 +1485,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remaining_route_inventory_does_not_overlap_contract() {
|
fn remaining_route_inventory_does_not_overlap_contract() {
|
||||||
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 20);
|
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 21);
|
||||||
for remaining in REMAINING_RUNTIME_ROUTES {
|
for remaining in REMAINING_RUNTIME_ROUTES {
|
||||||
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
||||||
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ impl_openapi_schema!(
|
|||||||
SubjektivMemoryQueryResponse,
|
SubjektivMemoryQueryResponse,
|
||||||
SubjektivMemoryReadResponse,
|
SubjektivMemoryReadResponse,
|
||||||
SubjektivMemoryListRevisionsResponse,
|
SubjektivMemoryListRevisionsResponse,
|
||||||
SubjektivSubjectWorkerStartRequest,
|
|
||||||
SubjektivStageCandidateRequest,
|
SubjektivStageCandidateRequest,
|
||||||
SubjektivStageCandidateResponse,
|
SubjektivStageCandidateResponse,
|
||||||
SubjektivRecordSessionRequest,
|
SubjektivRecordSessionRequest,
|
||||||
@@ -1234,21 +1233,6 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] subject_id: String,
|
#[path] subject_id: String,
|
||||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
#[post(
|
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker",
|
|
||||||
status = 200,
|
|
||||||
error_status = 400,
|
|
||||||
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
|
|
||||||
bearer_auth = true,
|
|
||||||
browser_auth = true,
|
|
||||||
)]
|
|
||||||
async fn subjektiv_subject_worker_start(
|
|
||||||
&self,
|
|
||||||
#[extension] context: ServerRequestContext,
|
|
||||||
#[path] workspace_id: String,
|
|
||||||
#[path] subject_id: String,
|
|
||||||
#[body] request: SubjektivSubjectWorkerStartRequest,
|
|
||||||
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
|
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/subjektiv/staging",
|
"/api/w/{workspace_id}/subjektiv/staging",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -4780,12 +4764,6 @@ pub struct SubjektivMemoryRevisionsQuery {
|
|||||||
pub cursor: Option<String>,
|
pub cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct SubjektivSubjectWorkerStartRequest {
|
|
||||||
pub worker: CreateWorkspaceWorkerRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SubjektivStageCandidateRequest {
|
pub struct SubjektivStageCandidateRequest {
|
||||||
@@ -9489,6 +9467,14 @@ pub struct WorkerLaunchProfileCandidate {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub feature_connections: WorkerLaunchFeatureConnectionOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerLaunchFeatureConnectionOptions {
|
||||||
|
pub subjektiv: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -9520,6 +9506,22 @@ pub struct CreateWorkspaceWorkerTicketAssignmentRequest {
|
|||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceWorkerFeatureConnectionsRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||||
|
pub subjektiv: Option<SubjektivWorkerConnectionRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivWorkerConnectionRequest {
|
||||||
|
pub subject_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -9537,6 +9539,9 @@ pub struct CreateWorkspaceWorkerRequest {
|
|||||||
pub initial_submit: Vec<protocol::Segment>,
|
pub initial_submit: Vec<protocol::Segment>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workdir_attachments: Vec<BrowserWorkerWorkingDirectorySelection>,
|
pub workdir_attachments: Vec<BrowserWorkerWorkingDirectorySelection>,
|
||||||
|
/// Explicit product Feature connections. Backend validation turns these into
|
||||||
|
/// trusted host attachments; Runtime and Profile inputs cannot self-attest them.
|
||||||
|
pub feature_connections: WorkspaceWorkerFeatureConnectionsRequest,
|
||||||
/// Backend idempotency key used only for authenticated Worker-owned spawn/control.
|
/// Backend idempotency key used only for authenticated Worker-owned spawn/control.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub control_operation_id: Option<String>,
|
pub control_operation_id: Option<String>,
|
||||||
@@ -10705,11 +10710,14 @@ pub fn worker_launch_api_typescript() -> String {
|
|||||||
RuntimeWorkerWorkdirAttachmentSummary::decl(&config),
|
RuntimeWorkerWorkdirAttachmentSummary::decl(&config),
|
||||||
WorkerLaunchWorkerSummary::decl(&config),
|
WorkerLaunchWorkerSummary::decl(&config),
|
||||||
WorkerLaunchRuntimeOption::decl(&config),
|
WorkerLaunchRuntimeOption::decl(&config),
|
||||||
|
WorkerLaunchFeatureConnectionOptions::decl(&config),
|
||||||
WorkerLaunchProfileCandidate::decl(&config),
|
WorkerLaunchProfileCandidate::decl(&config),
|
||||||
WorkingDirectoryRepositoryOption::decl(&config),
|
WorkingDirectoryRepositoryOption::decl(&config),
|
||||||
WorkerLaunchOptionsResponse::decl(&config),
|
WorkerLaunchOptionsResponse::decl(&config),
|
||||||
BrowserWorkerWorkingDirectorySelection::decl(&config),
|
BrowserWorkerWorkingDirectorySelection::decl(&config),
|
||||||
CreateWorkspaceWorkerTicketAssignmentRequest::decl(&config),
|
CreateWorkspaceWorkerTicketAssignmentRequest::decl(&config),
|
||||||
|
SubjektivWorkerConnectionRequest::decl(&config),
|
||||||
|
WorkspaceWorkerFeatureConnectionsRequest::decl(&config),
|
||||||
CreateWorkspaceWorkerRequest::decl(&config),
|
CreateWorkspaceWorkerRequest::decl(&config),
|
||||||
BrowserCreateWorkerResponse::decl(&config),
|
BrowserCreateWorkerResponse::decl(&config),
|
||||||
BrowserWorkspaceOrchestratorResponse::decl(&config),
|
BrowserWorkspaceOrchestratorResponse::decl(&config),
|
||||||
@@ -11750,6 +11758,7 @@ mod tests {
|
|||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
initial_submit: Vec::new(),
|
initial_submit: Vec::new(),
|
||||||
workdir_attachments: Vec::new(),
|
workdir_attachments: Vec::new(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
control_operation_id: None,
|
control_operation_id: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -11763,6 +11772,7 @@ mod tests {
|
|||||||
"ticket_assignment": null,
|
"ticket_assignment": null,
|
||||||
"initial_submit": [],
|
"initial_submit": [],
|
||||||
"workdir_attachments": [],
|
"workdir_attachments": [],
|
||||||
|
"feature_connections": {},
|
||||||
"control_operation_id": null,
|
"control_operation_id": null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -110,9 +110,12 @@ pub use public_snapshot::{
|
|||||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||||
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||||
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits,
|
MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedAttachmentReadError, RetainedHistoryReadError,
|
||||||
RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError,
|
RetainedHistoryReadLimits, RetainedSessionAttachment, RetainedSessionIdentity,
|
||||||
project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot,
|
RetainedSessionSnapshot, RetainedSnapshotReadError, project_session_snapshot,
|
||||||
|
read_retained_session_attachment, read_retained_session_history_page,
|
||||||
|
read_retained_session_snapshot, read_session_attachment_from_entries,
|
||||||
|
session_tool_attachment_id,
|
||||||
};
|
};
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
use base64::{
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
Engine as _,
|
|
||||||
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
|
|
||||||
};
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||||
@@ -54,6 +51,32 @@ pub enum RetainedSnapshotReadError {
|
|||||||
SnapshotTooLarge,
|
SnapshotTooLarge,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RetainedSessionAttachment {
|
||||||
|
pub media_type: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum RetainedAttachmentReadError {
|
||||||
|
#[error("retained worker state is unavailable")]
|
||||||
|
RetentionMissing,
|
||||||
|
#[error("retained worker state has no active session pointer")]
|
||||||
|
ActivePointerMissing,
|
||||||
|
#[error("retained session identity does not match")]
|
||||||
|
SessionMismatch,
|
||||||
|
#[error("session attachment was not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("retained session requires migration")]
|
||||||
|
MigrationRequired,
|
||||||
|
#[error("retained session log is corrupt")]
|
||||||
|
CorruptLog,
|
||||||
|
#[error("retained session storage is unavailable")]
|
||||||
|
StorageUnavailable,
|
||||||
|
#[error("retained session attachment exceeds a resource limit")]
|
||||||
|
ResourceLimit,
|
||||||
|
}
|
||||||
|
|
||||||
pub const DEFAULT_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
pub const DEFAULT_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
||||||
pub const MAX_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
pub const MAX_RETAINED_HISTORY_PAGE_TURNS: usize = 5;
|
||||||
pub const DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024;
|
pub const DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
@@ -175,7 +198,9 @@ pub fn read_retained_session_snapshot(
|
|||||||
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
||||||
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
||||||
.map_err(map_store_error)?;
|
.map_err(map_store_error)?;
|
||||||
if store.segment_log_len(segment_id).map_err(map_store_error)? > max_bytes {
|
if store.segment_log_len(segment_id).map_err(map_store_error)?
|
||||||
|
> DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES
|
||||||
|
{
|
||||||
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
||||||
}
|
}
|
||||||
let entries = store
|
let entries = store
|
||||||
@@ -199,6 +224,153 @@ pub fn read_retained_session_snapshot(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve one immutable image body from the active retained Session. The
|
||||||
|
/// caller supplies only public identities; storage paths never cross this API.
|
||||||
|
pub fn read_retained_session_attachment(
|
||||||
|
aggregate_root: &Path,
|
||||||
|
worker_name: &str,
|
||||||
|
expected_session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_scan_bytes: u64,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Result<RetainedSessionAttachment, RetainedAttachmentReadError> {
|
||||||
|
let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name)
|
||||||
|
.map_err(map_attachment_worker_store_error)?;
|
||||||
|
let metadata = aggregate
|
||||||
|
.read_read_only()
|
||||||
|
.map_err(map_attachment_worker_store_error)?
|
||||||
|
.ok_or(RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let active = metadata
|
||||||
|
.active
|
||||||
|
.ok_or(RetainedAttachmentReadError::ActivePointerMissing)?;
|
||||||
|
if active.session_id.to_string() != expected_session_id {
|
||||||
|
return Err(RetainedAttachmentReadError::SessionMismatch);
|
||||||
|
}
|
||||||
|
let segment_id = active
|
||||||
|
.segment_id
|
||||||
|
.ok_or(RetainedAttachmentReadError::ActivePointerMissing)?;
|
||||||
|
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
||||||
|
.map_err(map_attachment_store_error)?;
|
||||||
|
let limits = RetainedHistoryReadLimits {
|
||||||
|
max_scan_bytes,
|
||||||
|
..RetainedHistoryReadLimits::default()
|
||||||
|
};
|
||||||
|
let (lineage, _, mut scanned_bytes, mut scanned_entries) =
|
||||||
|
load_adopted_lineage(&store, active.session_id, segment_id, limits)
|
||||||
|
.map_err(map_history_attachment_error)?;
|
||||||
|
for segment in &lineage {
|
||||||
|
let adopted_end = locate_adopted_segment_end(
|
||||||
|
&store,
|
||||||
|
active.session_id,
|
||||||
|
segment,
|
||||||
|
limits,
|
||||||
|
&mut scanned_bytes,
|
||||||
|
&mut scanned_entries,
|
||||||
|
)
|
||||||
|
.map_err(map_history_attachment_error)?;
|
||||||
|
let mut reader = store
|
||||||
|
.open_retained_segment_reader(active.session_id, segment.segment_id, adopted_end)
|
||||||
|
.map_err(map_attachment_store_error)?;
|
||||||
|
loop {
|
||||||
|
let remaining = limits.max_scan_bytes.saturating_sub(scanned_bytes);
|
||||||
|
let (record, bytes) = reader
|
||||||
|
.previous_record(remaining)
|
||||||
|
.map_err(map_attachment_store_error)?;
|
||||||
|
scanned_bytes = scanned_bytes
|
||||||
|
.checked_add(bytes)
|
||||||
|
.ok_or(RetainedAttachmentReadError::ResourceLimit)?;
|
||||||
|
let Some(record) = record else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
scanned_entries = scanned_entries
|
||||||
|
.checked_add(persisted_entry_units(std::slice::from_ref(&record.entry)))
|
||||||
|
.ok_or(RetainedAttachmentReadError::ResourceLimit)?;
|
||||||
|
if scanned_entries > limits.max_entries {
|
||||||
|
return Err(RetainedAttachmentReadError::ResourceLimit);
|
||||||
|
}
|
||||||
|
if let Some(result) =
|
||||||
|
session_attachment_from_record(&record.entry, attachment_id, max_attachment_bytes)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RetainedAttachmentReadError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_session_attachment_from_entries(
|
||||||
|
entries: &[LogEntry],
|
||||||
|
attachment_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Result<RetainedSessionAttachment, RetainedAttachmentReadError> {
|
||||||
|
for record in entries {
|
||||||
|
if let Some(result) =
|
||||||
|
session_attachment_from_record(record, attachment_id, max_attachment_bytes)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RetainedAttachmentReadError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_attachment_from_record(
|
||||||
|
record: &LogEntry,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Option<Result<RetainedSessionAttachment, RetainedAttachmentReadError>> {
|
||||||
|
let mut found = None;
|
||||||
|
visit_record_history(record, &mut |entry| {
|
||||||
|
if found.is_none() {
|
||||||
|
found = attachment_from_entry(entry, attachment_id, max_attachment_bytes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_record_history(record: &LogEntry, visit: &mut impl FnMut(&LoggedHistoryEntry)) {
|
||||||
|
match record {
|
||||||
|
LogEntry::AnnotatedSegmentStart { history, .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { history, .. } => {
|
||||||
|
for entry in history {
|
||||||
|
visit(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { entry, .. } => visit(entry),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_from_entry(
|
||||||
|
entry: &LoggedHistoryEntry,
|
||||||
|
requested_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Option<Result<RetainedSessionAttachment, RetainedAttachmentReadError>> {
|
||||||
|
let LoggedItem::ToolResult { attachments, .. } = &entry.item else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
for (attachment_index, attachment) in attachments.iter().enumerate() {
|
||||||
|
let crate::logged_item::LoggedAttachment::Image { mime_type, data } = attachment;
|
||||||
|
if session_tool_attachment_id(
|
||||||
|
&entry.metadata.entry_id.0,
|
||||||
|
attachment_index,
|
||||||
|
mime_type,
|
||||||
|
data,
|
||||||
|
) != requested_id
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if data.len() as u64 > max_attachment_bytes {
|
||||||
|
return Some(Err(RetainedAttachmentReadError::ResourceLimit));
|
||||||
|
}
|
||||||
|
return Some(Ok(RetainedSessionAttachment {
|
||||||
|
media_type: mime_type.clone(),
|
||||||
|
data: data.clone(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Read one bounded backward page from the retained active Segment's adopted
|
/// Read one bounded backward page from the retained active Segment's adopted
|
||||||
/// lineage without creating, migrating, restoring, or rewriting persisted state.
|
/// lineage without creating, migrating, restoring, or rewriting persisted state.
|
||||||
///
|
///
|
||||||
@@ -935,6 +1107,54 @@ fn map_history_store_error(error: StoreError) -> RetainedHistoryReadError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_history_attachment_error(error: RetainedHistoryReadError) -> RetainedAttachmentReadError {
|
||||||
|
match error {
|
||||||
|
RetainedHistoryReadError::RetentionMissing => RetainedAttachmentReadError::RetentionMissing,
|
||||||
|
RetainedHistoryReadError::ActivePointerMissing => {
|
||||||
|
RetainedAttachmentReadError::ActivePointerMissing
|
||||||
|
}
|
||||||
|
RetainedHistoryReadError::MigrationRequired => {
|
||||||
|
RetainedAttachmentReadError::MigrationRequired
|
||||||
|
}
|
||||||
|
RetainedHistoryReadError::CorruptLog | RetainedHistoryReadError::InvalidCursor => {
|
||||||
|
RetainedAttachmentReadError::CorruptLog
|
||||||
|
}
|
||||||
|
RetainedHistoryReadError::StorageUnavailable => {
|
||||||
|
RetainedAttachmentReadError::StorageUnavailable
|
||||||
|
}
|
||||||
|
RetainedHistoryReadError::ResourceLimit => RetainedAttachmentReadError::ResourceLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_attachment_worker_store_error(error: WorkerStoreError) -> RetainedAttachmentReadError {
|
||||||
|
match error {
|
||||||
|
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
RetainedAttachmentReadError::RetentionMissing
|
||||||
|
}
|
||||||
|
WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => {
|
||||||
|
RetainedAttachmentReadError::CorruptLog
|
||||||
|
}
|
||||||
|
WorkerStoreError::Io(_) => RetainedAttachmentReadError::StorageUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_attachment_store_error(error: StoreError) -> RetainedAttachmentReadError {
|
||||||
|
match error {
|
||||||
|
StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
RetainedAttachmentReadError::RetentionMissing
|
||||||
|
}
|
||||||
|
StoreError::Corrupt { message, .. } if message.contains("requires migration") => {
|
||||||
|
RetainedAttachmentReadError::MigrationRequired
|
||||||
|
}
|
||||||
|
StoreError::ReadLimitExceeded => RetainedAttachmentReadError::ResourceLimit,
|
||||||
|
StoreError::Io(_) => RetainedAttachmentReadError::StorageUnavailable,
|
||||||
|
StoreError::Serde(_) | StoreError::Corrupt { .. } | StoreError::NotFound(_) => {
|
||||||
|
RetainedAttachmentReadError::CorruptLog
|
||||||
|
}
|
||||||
|
_ => RetainedAttachmentReadError::CorruptLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
|
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
|
||||||
match error {
|
match error {
|
||||||
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
@@ -1028,7 +1248,7 @@ pub(crate) fn project_session_snapshot_for_segment(
|
|||||||
} => extend_history(&mut entries, history, Some(segments), *ts),
|
} => extend_history(&mut entries, history, Some(segments), *ts),
|
||||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||||
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
||||||
if let Some(data) = project_item(&entry.item) {
|
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
|
||||||
entries.push(history_entry(entry, *ts, data));
|
entries.push(history_entry(entry, *ts, data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1188,7 +1408,7 @@ fn extend_history(
|
|||||||
segments: input_segments.cloned().unwrap_or_default(),
|
segments: input_segments.cloned().unwrap_or_default(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let Some(data) = project_item(&entry.item) else {
|
let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
data
|
data
|
||||||
@@ -1306,7 +1526,25 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
pub fn session_tool_attachment_id(
|
||||||
|
entry_id: &str,
|
||||||
|
attachment_index: usize,
|
||||||
|
media_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> String {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(b"yoi-session-tool-attachment-v1\0");
|
||||||
|
hash.update((entry_id.len() as u64).to_be_bytes());
|
||||||
|
hash.update(entry_id.as_bytes());
|
||||||
|
hash.update((attachment_index as u64).to_be_bytes());
|
||||||
|
hash.update((media_type.len() as u64).to_be_bytes());
|
||||||
|
hash.update(media_type.as_bytes());
|
||||||
|
hash.update((data.len() as u64).to_be_bytes());
|
||||||
|
hash.update(data);
|
||||||
|
URL_SAFE_NO_PAD.encode(hash.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_item(entry_id: &str, item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||||
match item {
|
match item {
|
||||||
LoggedItem::Message { role, content } => {
|
LoggedItem::Message { role, content } => {
|
||||||
let role = match role {
|
let role = match role {
|
||||||
@@ -1355,11 +1593,18 @@ fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
|||||||
is_error: *is_error,
|
is_error: *is_error,
|
||||||
attachments: attachments
|
attachments: attachments
|
||||||
.iter()
|
.iter()
|
||||||
.map(|attachment| match attachment {
|
.enumerate()
|
||||||
|
.map(|(attachment_index, attachment)| match attachment {
|
||||||
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
||||||
SessionToolAttachment {
|
SessionToolAttachment {
|
||||||
|
attachment_id: session_tool_attachment_id(
|
||||||
|
entry_id,
|
||||||
|
attachment_index,
|
||||||
|
mime_type,
|
||||||
|
data,
|
||||||
|
),
|
||||||
media_type: mime_type.clone(),
|
media_type: mime_type.clone(),
|
||||||
data_base64: BASE64.encode(data),
|
byte_len: data.len() as u64,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3307,6 +3552,206 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_attachment_resolves_from_an_adopted_compaction_ancestor() {
|
||||||
|
let worker_name = "worker-ancestor-image";
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let source_segment = crate::new_segment_id();
|
||||||
|
let active_segment = crate::new_segment_id();
|
||||||
|
let image_entry = LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::ToolResult {
|
||||||
|
call_id: "call-ancestor-image".into(),
|
||||||
|
summary: "ancestor screenshot".into(),
|
||||||
|
content: None,
|
||||||
|
attachments: vec![crate::logged_item::LoggedAttachment::Image {
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
data: vec![7, 8, 9],
|
||||||
|
}],
|
||||||
|
disposition: agen::tool::ToolResultDisposition::Success,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId("ancestor-image".into()),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)];
|
||||||
|
source_log.push(LogEntry::AnnotatedUserInput {
|
||||||
|
ts: 2,
|
||||||
|
segments: vec![Segment::Text {
|
||||||
|
content: "show image".into(),
|
||||||
|
}],
|
||||||
|
history: vec![user_message(1)],
|
||||||
|
extensions: Vec::new(),
|
||||||
|
});
|
||||||
|
source_log.push(LogEntry::AnnotatedToolResult {
|
||||||
|
ts: 3,
|
||||||
|
entry: image_entry,
|
||||||
|
});
|
||||||
|
source_log.push(LogEntry::TurnEnd {
|
||||||
|
ts: 4,
|
||||||
|
turn_count: 1,
|
||||||
|
});
|
||||||
|
let active_log = vec![segment_start(
|
||||||
|
session_id,
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
Some(SegmentOrigin {
|
||||||
|
segment_id: source_segment,
|
||||||
|
at_turn_index: 1,
|
||||||
|
}),
|
||||||
|
)];
|
||||||
|
let (_root, aggregate_root) = persist_history_fixture(
|
||||||
|
worker_name,
|
||||||
|
session_id,
|
||||||
|
active_segment,
|
||||||
|
vec![(source_segment, source_log), (active_segment, active_log)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let page = read_retained_session_history_page(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
RetainedHistoryReadLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let attachment = page.turns[0]
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find_map(|entry| match &entry.data {
|
||||||
|
SessionSnapshotEntryData::ToolResult { attachments, .. } => attachments.first(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("ancestor attachment reference");
|
||||||
|
let body = read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&session_id.to_string(),
|
||||||
|
&attachment.attachment_id,
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.media_type, "image/png");
|
||||||
|
assert_eq!(body.data, vec![7, 8, 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_legacy_inline_images_project_as_bounded_refs_and_fetch_from_retained_storage() {
|
||||||
|
let worker_name = "worker-images";
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let segment_id = crate::new_segment_id();
|
||||||
|
// Approximate the decoded byte sizes of the four W-296 screenshots.
|
||||||
|
let sizes = [3_475_194_usize, 3_459_051, 5_735_340, 5_703_924];
|
||||||
|
let attachments = sizes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(
|
||||||
|
|(index, size)| crate::logged_item::LoggedAttachment::Image {
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
data: vec![index as u8 + 1; *size],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
let tool_result = LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::ToolResult {
|
||||||
|
call_id: "call-images".into(),
|
||||||
|
summary: "four screenshots".into(),
|
||||||
|
content: None,
|
||||||
|
attachments,
|
||||||
|
disposition: agen::tool::ToolResultDisposition::Success,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId("tool-images".into()),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let live = project_current_session_snapshot(&[LogEntry::AnnotatedToolResult {
|
||||||
|
ts: 2,
|
||||||
|
entry: tool_result.clone(),
|
||||||
|
}]);
|
||||||
|
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![tool_result],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}];
|
||||||
|
let (_root, aggregate_root) =
|
||||||
|
persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]);
|
||||||
|
|
||||||
|
let retained = read_retained_session_snapshot(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let SessionSnapshotEntryData::ToolResult { attachments, .. } =
|
||||||
|
&retained.snapshot.entries[0].data
|
||||||
|
else {
|
||||||
|
panic!("expected tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(attachments.len(), 4);
|
||||||
|
let SessionSnapshotEntryData::ToolResult {
|
||||||
|
attachments: live_attachments,
|
||||||
|
..
|
||||||
|
} = &live.entries[0].data
|
||||||
|
else {
|
||||||
|
panic!("expected live tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(live_attachments, attachments);
|
||||||
|
let snapshot_json = serde_json::to_vec(&retained.snapshot).unwrap();
|
||||||
|
assert!(snapshot_json.len() < 16 * 1024);
|
||||||
|
assert!(!String::from_utf8_lossy(&snapshot_json).contains("data_base64"));
|
||||||
|
|
||||||
|
for (index, attachment) in attachments.iter().enumerate() {
|
||||||
|
assert_eq!(attachment.byte_len, sizes[index] as u64);
|
||||||
|
assert_eq!(attachment.attachment_id.len(), 43);
|
||||||
|
let body = read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&session_id.to_string(),
|
||||||
|
&attachment.attachment_id,
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.media_type, "image/png");
|
||||||
|
assert_eq!(body.data.len(), sizes[index]);
|
||||||
|
assert!(body.data.iter().all(|byte| *byte == index as u8 + 1));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&crate::new_session_id().to_string(),
|
||||||
|
&attachments[0].attachment_id,
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
RetainedAttachmentReadError::SessionMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_retained_session_attachment(
|
||||||
|
&aggregate_root,
|
||||||
|
worker_name,
|
||||||
|
&session_id.to_string(),
|
||||||
|
"../../metadata.json",
|
||||||
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
RetainedAttachmentReadError::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retained_reads_do_not_create_missing_aggregate() {
|
fn retained_reads_do_not_create_missing_aggregate() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -3761,6 +3761,7 @@ mod completion_flow_tests {
|
|||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::WorkerState {
|
app.handle_worker_event(Event::WorkerState {
|
||||||
snapshot: running.clone(),
|
snapshot: running.clone(),
|
||||||
@@ -3780,6 +3781,7 @@ mod completion_flow_tests {
|
|||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
last_command_id: 2,
|
last_command_id: 2,
|
||||||
|
last_finished_submission_request_id: Some("request-old".to_string()),
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::WorkerState {
|
app.handle_worker_event(Event::WorkerState {
|
||||||
snapshot: running.clone(),
|
snapshot: running.clone(),
|
||||||
@@ -3789,6 +3791,7 @@ mod completion_flow_tests {
|
|||||||
let fresh_idle = WorkerStateSnapshot {
|
let fresh_idle = WorkerStateSnapshot {
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::WorkerState {
|
app.handle_worker_event(Event::WorkerState {
|
||||||
snapshot: fresh_idle.clone(),
|
snapshot: fresh_idle.clone(),
|
||||||
@@ -3800,6 +3803,7 @@ mod completion_flow_tests {
|
|||||||
protocol::WorkerRunState::Paused,
|
protocol::WorkerRunState::Paused,
|
||||||
)),
|
)),
|
||||||
last_command_id: 3,
|
last_command_id: 3,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::CommandAcknowledged {
|
app.handle_worker_event(Event::CommandAcknowledged {
|
||||||
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
|
|||||||
initial_submit: Vec::new(),
|
initial_submit: Vec::new(),
|
||||||
workdir_attachments: Vec::new(),
|
workdir_attachments: Vec::new(),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
|
feature_connections: Default::default(),
|
||||||
control_operation_id: None,
|
control_operation_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,11 +432,13 @@ mod tests {
|
|||||||
id: "builtin:default".to_string(),
|
id: "builtin:default".to_string(),
|
||||||
label: "Default".to_string(),
|
label: "Default".to_string(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
},
|
},
|
||||||
BackendWorkerLaunchProfileCandidate {
|
BackendWorkerLaunchProfileCandidate {
|
||||||
id: "builtin:coder".to_string(),
|
id: "builtin:coder".to_string(),
|
||||||
label: "Coder".to_string(),
|
label: "Coder".to_string(),
|
||||||
description: "Ticket implementation".to_string(),
|
description: "Ticket implementation".to_string(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
default_profile: Some("builtin:coder".to_string()),
|
default_profile: Some("builtin:coder".to_string()),
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ mod tests {
|
|||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
}),
|
}),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -869,6 +870,7 @@ mod tests {
|
|||||||
short.worker_state = Some(protocol::WorkerStateSnapshot {
|
short.worker_state = Some(protocol::WorkerStateSnapshot {
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut long = worker("runtime-a", "worker-b", None);
|
let mut long = worker("runtime-a", "worker-b", None);
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ impl RetainedSnapshotSocket {
|
|||||||
},
|
},
|
||||||
state: protocol::WorkerStateSnapshot {
|
state: protocol::WorkerStateSnapshot {
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
},
|
},
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ pub struct WorkerDetail {
|
|||||||
pub created_at_ms: Option<u64>,
|
pub created_at_ms: Option<u64>,
|
||||||
/// Whether the persisted execution metadata was valid when this identity was loaded.
|
/// Whether the persisted execution metadata was valid when this identity was loaded.
|
||||||
pub execution_metadata_available: bool,
|
pub execution_metadata_available: bool,
|
||||||
|
/// Whether this Runtime process reconstructed the Worker from durable execution state.
|
||||||
|
/// This proves that work from the prior process incarnation is no longer running.
|
||||||
|
#[serde(default)]
|
||||||
|
pub execution_reconstructed: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -514,6 +514,13 @@ pub struct WorkerSessionHistoryRequest {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WorkerSessionAttachmentRequest {
|
||||||
|
pub worker_ref: WorkerRef,
|
||||||
|
pub session_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
fn backend_id(&self) -> &str;
|
fn backend_id(&self) -> &str;
|
||||||
|
|
||||||
@@ -544,6 +551,14 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
_request: WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
Err(session_store::RetainedAttachmentReadError::RetentionMissing)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||||
|
|
||||||
/// Read-only restore validation performed before any live controller,
|
/// Read-only restore validation performed before any live controller,
|
||||||
@@ -780,6 +795,14 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.worker_session_history(request)
|
self.backend.worker_session_history(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
request: WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
self.backend.worker_session_attachment(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn spawn_worker(
|
pub(crate) fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ pub(crate) struct PersistedWorkerRecord {
|
|||||||
pub(crate) config_bundle: Option<ConfigBundleRef>,
|
pub(crate) config_bundle: Option<ConfigBundleRef>,
|
||||||
pub(crate) created_at_ms: Option<u64>,
|
pub(crate) created_at_ms: Option<u64>,
|
||||||
pub(crate) status: WorkerStatus,
|
pub(crate) status: WorkerStatus,
|
||||||
|
pub(crate) last_finished_submission_request_id: Option<String>,
|
||||||
pub(crate) execution_state: PersistedWorkerExecutionState,
|
pub(crate) execution_state: PersistedWorkerExecutionState,
|
||||||
pub(crate) workspace_id: Option<String>,
|
pub(crate) workspace_id: Option<String>,
|
||||||
pub(crate) workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
|
pub(crate) workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
|
||||||
@@ -1824,6 +1825,8 @@ struct WorkerAggregateRecord {
|
|||||||
created_at_ms: Option<u64>,
|
created_at_ms: Option<u64>,
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
last_finished_submission_request_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
|
workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
|
||||||
@@ -1874,6 +1877,7 @@ impl WorkerAggregateRecord {
|
|||||||
config_bundle: worker.config_bundle.clone(),
|
config_bundle: worker.config_bundle.clone(),
|
||||||
created_at_ms: worker.created_at_ms,
|
created_at_ms: worker.created_at_ms,
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
|
last_finished_submission_request_id: worker.last_finished_submission_request_id.clone(),
|
||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
workdir_attachments: worker.workdir_attachments.clone(),
|
workdir_attachments: worker.workdir_attachments.clone(),
|
||||||
logical_workdir_attachments: worker.logical_workdir_attachments.clone(),
|
logical_workdir_attachments: worker.logical_workdir_attachments.clone(),
|
||||||
@@ -1893,6 +1897,7 @@ impl WorkerAggregateRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate(self, path: &Path) -> Result<PersistedWorkerRecord, RuntimeError> {
|
fn validate(self, path: &Path) -> Result<PersistedWorkerRecord, RuntimeError> {
|
||||||
|
let last_finished_submission_request_id = self.last_finished_submission_request_id;
|
||||||
let identity = WorkerIdentityRecord {
|
let identity = WorkerIdentityRecord {
|
||||||
schema_version: self.schema_version,
|
schema_version: self.schema_version,
|
||||||
worker_ref: self.worker_ref,
|
worker_ref: self.worker_ref,
|
||||||
@@ -1984,7 +1989,9 @@ impl WorkerAggregateRecord {
|
|||||||
}
|
}
|
||||||
WorkerExecutionStateRecord::Unavailable => PersistedWorkerExecutionState::Unavailable,
|
WorkerExecutionStateRecord::Unavailable => PersistedWorkerExecutionState::Unavailable,
|
||||||
};
|
};
|
||||||
Ok(identity.into_persisted(execution_state))
|
let mut persisted = identity.into_persisted(execution_state);
|
||||||
|
persisted.last_finished_submission_request_id = last_finished_submission_request_id;
|
||||||
|
Ok(persisted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2164,6 +2171,7 @@ impl WorkerIdentityRecord {
|
|||||||
config_bundle: self.config_bundle,
|
config_bundle: self.config_bundle,
|
||||||
created_at_ms: self.created_at_ms,
|
created_at_ms: self.created_at_ms,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
execution_state,
|
execution_state,
|
||||||
workspace_id: self.workspace_id,
|
workspace_id: self.workspace_id,
|
||||||
workdir_attachments,
|
workdir_attachments,
|
||||||
|
|||||||
@@ -336,6 +336,13 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
.route(
|
.route(
|
||||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]),
|
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]),
|
||||||
delete(delete_worker_uploaded_file),
|
delete(delete_worker_uploaded_file),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
remaining_route(
|
||||||
|
runtime_api::RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT,
|
||||||
|
&["GET"],
|
||||||
|
),
|
||||||
|
get(get_worker_session_attachment),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -1980,6 +1987,60 @@ async fn delete_worker_uploaded_file(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_worker_session_attachment(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
Path((worker_id, session_id, attachment_id)): Path<(String, String, String)>,
|
||||||
|
Query(request): Query<runtime_api::WorkerSessionRequest>,
|
||||||
|
) -> Result<Response, RuntimeHttpRestError> {
|
||||||
|
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||||
|
let scope = auth_workspace_scope(&state, auth.as_ref())?.ok_or_else(|| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"runtime_worker_session_scope_required",
|
||||||
|
"Worker Session attachment reads require a Workspace-scoped capability",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if scope.workspace_id != request.workspace_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"runtime_worker_session_workspace_scope_mismatch",
|
||||||
|
"Worker Session Workspace scope does not match the authenticated capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attachment = state
|
||||||
|
.runtime
|
||||||
|
.worker_session_attachment_scoped(&scope, &worker_ref, session_id, attachment_id)
|
||||||
|
.map_err(|error| {
|
||||||
|
use session_store::RetainedAttachmentReadError as Error;
|
||||||
|
let (status, code) = match error {
|
||||||
|
Error::RetentionMissing | Error::ActivePointerMissing => {
|
||||||
|
(StatusCode::GONE, "session_attachment_expired")
|
||||||
|
}
|
||||||
|
Error::SessionMismatch | Error::NotFound => {
|
||||||
|
(StatusCode::NOT_FOUND, "session_attachment_not_found")
|
||||||
|
}
|
||||||
|
Error::ResourceLimit => (
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"session_attachment_too_large",
|
||||||
|
),
|
||||||
|
Error::MigrationRequired | Error::CorruptLog | Error::StorageUnavailable => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"session_attachment_unavailable",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
RuntimeHttpRestError::new(status, code, error.to_string())
|
||||||
|
})?;
|
||||||
|
let content_type = axum::http::HeaderValue::from_str(&attachment.media_type).map_err(|_| {
|
||||||
|
RuntimeHttpRestError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"session_attachment_media_type_invalid",
|
||||||
|
"Session attachment media type is invalid",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, content_type)], attachment.data).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn stop_worker(
|
async fn stop_worker(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
@@ -2289,7 +2350,7 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
}
|
}
|
||||||
if path.ends_with("/input")
|
if path.ends_with("/input")
|
||||||
|| path.ends_with("/restore")
|
|| path.ends_with("/restore")
|
||||||
|| path.contains("/attachments")
|
|| (path.contains("/attachments") && *method != Method::GET)
|
||||||
|| path.contains("/workdir-attachments")
|
|| path.contains("/workdir-attachments")
|
||||||
{
|
{
|
||||||
return Some("workers:input");
|
return Some("workers:input");
|
||||||
@@ -3031,6 +3092,13 @@ mod tests {
|
|||||||
),
|
),
|
||||||
Some("workers:input")
|
Some("workers:input")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(
|
||||||
|
&Method::GET,
|
||||||
|
"/v1/workers/7/sessions/session-1/attachments/attachment-1"
|
||||||
|
),
|
||||||
|
Some("workers:read")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/workers/7/workdir-attachments"),
|
required_runtime_permission(&Method::POST, "/v1/workers/7/workdir-attachments"),
|
||||||
Some("workers:input")
|
Some("workers:input")
|
||||||
|
|||||||
@@ -1095,6 +1095,8 @@ impl Runtime {
|
|||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
status: WorkerStatus::Stopped,
|
status: WorkerStatus::Stopped,
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
|
execution_reconstructed: false,
|
||||||
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
||||||
profile: durable_request.profile.clone(),
|
profile: durable_request.profile.clone(),
|
||||||
display_name: durable_request.display_name.clone(),
|
display_name: durable_request.display_name.clone(),
|
||||||
@@ -1673,6 +1675,39 @@ impl Runtime {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_attachment_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
session_id: String,
|
||||||
|
attachment_id: String,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
let operation_lock = self
|
||||||
|
.worker_operation_lock(worker_ref.worker_id)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let _operation_guard = operation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
self.ensure_worker_in_workspace(scope, worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::NotFound)?;
|
||||||
|
let backend = {
|
||||||
|
let state = self
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
state
|
||||||
|
.worker(worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::NotFound)?;
|
||||||
|
state.execution_backend.clone()
|
||||||
|
}
|
||||||
|
.ok_or(session_store::RetainedAttachmentReadError::StorageUnavailable)?;
|
||||||
|
backend.worker_session_attachment(crate::execution::WorkerSessionAttachmentRequest {
|
||||||
|
worker_ref: worker_ref.clone(),
|
||||||
|
session_id,
|
||||||
|
attachment_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
||||||
pub fn restore_worker_scoped(
|
pub fn restore_worker_scoped(
|
||||||
&self,
|
&self,
|
||||||
@@ -3386,6 +3421,16 @@ impl Runtime {
|
|||||||
candidate.pending_stop = None;
|
candidate.pending_stop = None;
|
||||||
candidate.restore_candidate_context = None;
|
candidate.restore_candidate_context = None;
|
||||||
candidate.status = status;
|
candidate.status = status;
|
||||||
|
let mut worker_state = worker_state;
|
||||||
|
if worker_state.last_finished_submission_request_id.is_none() {
|
||||||
|
// The restored controller starts with fresh live state, but this
|
||||||
|
// historical fence proves that a previously accepted Submit Run
|
||||||
|
// already returned. Preserve it across process reconstruction so
|
||||||
|
// durable consumers can finish post-restart cleanup without
|
||||||
|
// waiting for an old request to run again.
|
||||||
|
worker_state.last_finished_submission_request_id =
|
||||||
|
candidate.last_finished_submission_request_id.clone();
|
||||||
|
}
|
||||||
let _ = candidate.apply_worker_state(&worker_state);
|
let _ = candidate.apply_worker_state(&worker_state);
|
||||||
candidate.restore_intent = restore_intent_for_status(candidate.status);
|
candidate.restore_intent = restore_intent_for_status(candidate.status);
|
||||||
candidate.workdir_attachments = workdir_attachments;
|
candidate.workdir_attachments = workdir_attachments;
|
||||||
@@ -3996,6 +4041,8 @@ impl RuntimeState {
|
|||||||
worker_id: worker.worker_id,
|
worker_id: worker.worker_id,
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
|
last_finished_submission_request_id: worker.last_finished_submission_request_id,
|
||||||
|
execution_reconstructed: true,
|
||||||
workspace_id: worker.workspace_id,
|
workspace_id: worker.workspace_id,
|
||||||
profile: worker.profile,
|
profile: worker.profile,
|
||||||
display_name: worker.display_name,
|
display_name: worker.display_name,
|
||||||
@@ -4893,6 +4940,8 @@ struct WorkerRecord {
|
|||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
worker_state: Option<protocol::WorkerStateSnapshot>,
|
worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||||
|
last_finished_submission_request_id: Option<String>,
|
||||||
|
execution_reconstructed: bool,
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
profile: ProfileSelector,
|
profile: ProfileSelector,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
@@ -4914,6 +4963,9 @@ struct WorkerRecord {
|
|||||||
|
|
||||||
impl WorkerRecord {
|
impl WorkerRecord {
|
||||||
fn apply_worker_state(&mut self, incoming: &protocol::WorkerStateSnapshot) {
|
fn apply_worker_state(&mut self, incoming: &protocol::WorkerStateSnapshot) {
|
||||||
|
if let Some(request_id) = &incoming.last_finished_submission_request_id {
|
||||||
|
self.last_finished_submission_request_id = Some(request_id.clone());
|
||||||
|
}
|
||||||
self.worker_state = Some(incoming.clone());
|
self.worker_state = Some(incoming.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4959,6 +5011,7 @@ impl WorkerRecord {
|
|||||||
status: self.status,
|
status: self.status,
|
||||||
created_at_ms: self.created_at_ms,
|
created_at_ms: self.created_at_ms,
|
||||||
execution_metadata_available: self.execution_metadata_available,
|
execution_metadata_available: self.execution_metadata_available,
|
||||||
|
execution_reconstructed: self.execution_reconstructed,
|
||||||
worker_state: self.worker_state.clone(),
|
worker_state: self.worker_state.clone(),
|
||||||
workspace_id: self.workspace_id.clone(),
|
workspace_id: self.workspace_id.clone(),
|
||||||
workdir_attachments: self.workdir_attachments.clone(),
|
workdir_attachments: self.workdir_attachments.clone(),
|
||||||
@@ -5022,6 +5075,7 @@ impl WorkerRecord {
|
|||||||
config_bundle: self.config_bundle.clone(),
|
config_bundle: self.config_bundle.clone(),
|
||||||
created_at_ms: self.created_at_ms,
|
created_at_ms: self.created_at_ms,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
|
last_finished_submission_request_id: self.last_finished_submission_request_id.clone(),
|
||||||
execution_state,
|
execution_state,
|
||||||
workspace_id: self.workspace_id.clone(),
|
workspace_id: self.workspace_id.clone(),
|
||||||
workdir_attachments: self.workdir_attachments.clone(),
|
workdir_attachments: self.workdir_attachments.clone(),
|
||||||
@@ -8028,6 +8082,7 @@ mod tests {
|
|||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
last_command_id: 2,
|
last_command_id: 2,
|
||||||
|
last_finished_submission_request_id: Some("request-old".to_string()),
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let mut state = runtime.lock().unwrap();
|
let mut state = runtime.lock().unwrap();
|
||||||
@@ -8049,6 +8104,7 @@ mod tests {
|
|||||||
let fresh = protocol::WorkerStateSnapshot {
|
let fresh = protocol::WorkerStateSnapshot {
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let mut state = runtime.lock().unwrap();
|
let mut state = runtime.lock().unwrap();
|
||||||
@@ -9196,6 +9252,81 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
#[test]
|
||||||
|
fn fs_store_preserves_finished_submission_fence_across_active_restore() {
|
||||||
|
let root = fs_store_root("finished-submission-restore");
|
||||||
|
let options = crate::fs_store::FsRuntimeStoreOptions {
|
||||||
|
root: root.clone(),
|
||||||
|
runtime_id: "test-runtime".to_string(),
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
let execution = Arc::new(TestExecutionBackend::default());
|
||||||
|
let runtime =
|
||||||
|
Runtime::with_fs_store_and_execution_backend(options.clone(), execution.clone())
|
||||||
|
.unwrap();
|
||||||
|
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||||
|
let worker = runtime
|
||||||
|
.create_worker(task_request("persist finished submission fence"))
|
||||||
|
.unwrap();
|
||||||
|
execution.set_dispatch_result(
|
||||||
|
WorkerExecutionResult::accepted_submission(
|
||||||
|
WorkerExecutionOperation::Input,
|
||||||
|
"job-attempt-request",
|
||||||
|
"submission-id",
|
||||||
|
protocol::SubmissionDisposition::Started,
|
||||||
|
)
|
||||||
|
.with_worker_state(protocol::WorkerStateSnapshot {
|
||||||
|
state: protocol::WorkerState::Idle,
|
||||||
|
// Production Submit Run does not consume a lifecycle command
|
||||||
|
// id, so this intentionally remains zero.
|
||||||
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: Some("job-attempt-request".to_string()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut input = WorkerInput::user("run durable Backend Job attempt");
|
||||||
|
input.submission_request_id = Some("job-attempt-request".to_string());
|
||||||
|
runtime.send_input(&worker.worker_ref, input).unwrap();
|
||||||
|
assert!(
|
||||||
|
!runtime
|
||||||
|
.worker_detail(&worker.worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.execution_reconstructed
|
||||||
|
);
|
||||||
|
|
||||||
|
let aggregate_path = root
|
||||||
|
.join("workers")
|
||||||
|
.join(worker.worker_id.to_string())
|
||||||
|
.join("worker.json");
|
||||||
|
let aggregate: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(&aggregate_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
aggregate["last_finished_submission_request_id"],
|
||||||
|
serde_json::json!("job-attempt-request")
|
||||||
|
);
|
||||||
|
drop(runtime);
|
||||||
|
|
||||||
|
let restored = Runtime::with_fs_store_and_execution_backend(
|
||||||
|
options,
|
||||||
|
Arc::new(TestExecutionBackend::default()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let restored_detail = restored.worker_detail(&worker.worker_ref).unwrap();
|
||||||
|
assert!(restored_detail.execution_reconstructed);
|
||||||
|
let restored_state = restored_detail
|
||||||
|
.worker_state
|
||||||
|
.expect("automatic restore publishes fresh Worker state");
|
||||||
|
assert_eq!(restored_state.last_command_id, 0);
|
||||||
|
assert_eq!(
|
||||||
|
restored_state
|
||||||
|
.last_finished_submission_request_id
|
||||||
|
.as_deref(),
|
||||||
|
Some("job-attempt-request")
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_store_explicitly_retries_rejected_automatic_restore() {
|
fn fs_store_explicitly_retries_rejected_automatic_restore() {
|
||||||
|
|||||||
@@ -186,6 +186,16 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
|||||||
Err(session_store::RetainedHistoryReadError::RetentionMissing)
|
Err(session_store::RetainedHistoryReadError::RetentionMissing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_attachment(
|
||||||
|
&self,
|
||||||
|
_worker_ref: &WorkerRef,
|
||||||
|
_session_id: &str,
|
||||||
|
_attachment_id: &str,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
Err(session_store::RetainedAttachmentReadError::RetentionMissing)
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_controller(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
@@ -1116,6 +1126,27 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn retained_session_attachment(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
let aggregate_dir = self
|
||||||
|
.worker_aggregate_dir(worker_ref)
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::RetentionMissing)?;
|
||||||
|
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
|
||||||
|
session_store::read_retained_session_attachment(
|
||||||
|
&aggregate_dir,
|
||||||
|
&worker_name,
|
||||||
|
session_id,
|
||||||
|
attachment_id,
|
||||||
|
session_store::DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
@@ -2412,6 +2443,36 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
request: crate::execution::WorkerSessionAttachmentRequest,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
let live = self
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| session_store::RetainedAttachmentReadError::StorageUnavailable)?
|
||||||
|
.get(&request.worker_ref)
|
||||||
|
.map(|execution| execution.handle.clone());
|
||||||
|
if let Some(handle) = live {
|
||||||
|
match handle.session_attachment(
|
||||||
|
&request.session_id,
|
||||||
|
&request.attachment_id,
|
||||||
|
10 * 1024 * 1024,
|
||||||
|
) {
|
||||||
|
Ok(attachment) => return Ok(attachment),
|
||||||
|
Err(session_store::RetainedAttachmentReadError::SessionMismatch)
|
||||||
|
| Err(session_store::RetainedAttachmentReadError::NotFound) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.factory.retained_session_attachment(
|
||||||
|
&request.worker_ref,
|
||||||
|
&request.session_id,
|
||||||
|
&request.attachment_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn observe_workspace_prompt_projection(
|
fn observe_workspace_prompt_projection(
|
||||||
&self,
|
&self,
|
||||||
projection: worker::WorkspacePromptProjection,
|
projection: worker::WorkspacePromptProjection,
|
||||||
@@ -3824,11 +3885,13 @@ mod tests {
|
|||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
last_command_id: 2,
|
last_command_id: 2,
|
||||||
|
last_finished_submission_request_id: Some("request-old".to_string()),
|
||||||
};
|
};
|
||||||
let current = Arc::new(RwLock::new(running));
|
let current = Arc::new(RwLock::new(running));
|
||||||
let idle = protocol::WorkerStateSnapshot {
|
let idle = protocol::WorkerStateSnapshot {
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
let mut replacement = Event::WorkerState {
|
let mut replacement = Event::WorkerState {
|
||||||
snapshot: idle.clone(),
|
snapshot: idle.clone(),
|
||||||
@@ -3841,6 +3904,7 @@ mod tests {
|
|||||||
protocol::WorkerRunState::Paused,
|
protocol::WorkerRunState::Paused,
|
||||||
)),
|
)),
|
||||||
last_command_id: 3,
|
last_command_id: 3,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
};
|
};
|
||||||
let mut acknowledgement = Event::CommandAcknowledged {
|
let mut acknowledgement = Event::CommandAcknowledged {
|
||||||
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ impl WorkerHandle {
|
|||||||
self.sink.subscribe_with_snapshot().0
|
self.sink.subscribe_with_snapshot().0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn session_attachment(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
if session_id == self.session_id.to_string() {
|
||||||
|
return session_store::read_session_attachment_from_entries(
|
||||||
|
&self.committed_entries(),
|
||||||
|
attachment_id,
|
||||||
|
max_attachment_bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.spawned_registry
|
||||||
|
.session_attachment(session_id, attachment_id, max_attachment_bytes)
|
||||||
|
.unwrap_or(Err(
|
||||||
|
session_store::RetainedAttachmentReadError::SessionMismatch,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn snapshot_event(&self) -> Event {
|
pub fn snapshot_event(&self) -> Event {
|
||||||
self.snapshot_event_with_entry_subscription().0
|
self.snapshot_event_with_entry_subscription().0
|
||||||
}
|
}
|
||||||
@@ -300,6 +321,7 @@ async fn finish_controller_run<C, St>(
|
|||||||
runtime_dir: &RuntimeDir,
|
runtime_dir: &RuntimeDir,
|
||||||
working_event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
new_status: WorkerStatus,
|
new_status: WorkerStatus,
|
||||||
|
finished_submission_request_id: Option<String>,
|
||||||
) where
|
) where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||||
@@ -320,7 +342,20 @@ async fn finish_controller_run<C, St>(
|
|||||||
Some(worker.total_tokens().tokens),
|
Some(worker.total_tokens().tokens),
|
||||||
protocol::ContextTokenSource::Estimated,
|
protocol::ContextTokenSource::Estimated,
|
||||||
);
|
);
|
||||||
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
if let Some(submission_request_id) = finished_submission_request_id {
|
||||||
|
let state = match new_status {
|
||||||
|
WorkerStatus::Idle | WorkerStatus::Stopped => WorkerState::Idle,
|
||||||
|
WorkerStatus::Running => {
|
||||||
|
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running))
|
||||||
|
}
|
||||||
|
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
|
||||||
|
};
|
||||||
|
let snapshot = shared_state.finish_submission_run(state, submission_request_id);
|
||||||
|
let _ = runtime_dir.write_status(shared_state).await;
|
||||||
|
let _ = working_event_tx.send(Event::WorkerState { snapshot });
|
||||||
|
} else {
|
||||||
|
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pending turn launch staged by an event handler for the next outer-loop
|
/// Pending turn launch staged by an event handler for the next outer-loop
|
||||||
@@ -2023,6 +2058,10 @@ async fn controller_loop<C, St>(
|
|||||||
// interrupted/error turn from being carried into the next snapshot.
|
// interrupted/error turn from being carried into the next snapshot.
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
let parent_originated = run.is_parent_originated();
|
let parent_originated = run.is_parent_originated();
|
||||||
|
let finished_submission_request_id = match &run {
|
||||||
|
PendingRun::Submit(submission) => Some(submission.submission_request_id.clone()),
|
||||||
|
PendingRun::RunForNotification { .. } | PendingRun::Resume => None,
|
||||||
|
};
|
||||||
let user_input_submit = matches!(&run, PendingRun::Submit(_));
|
let user_input_submit = matches!(&run, PendingRun::Submit(_));
|
||||||
if !user_input_submit {
|
if !user_input_submit {
|
||||||
set_controller_status(
|
set_controller_status(
|
||||||
@@ -2121,12 +2160,16 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let finished_submission_request_id = (new_status != WorkerStatus::Paused)
|
||||||
|
.then_some(finished_submission_request_id)
|
||||||
|
.flatten();
|
||||||
finish_controller_run(
|
finish_controller_run(
|
||||||
&mut worker,
|
&mut worker,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&runtime_dir,
|
&runtime_dir,
|
||||||
&working_event_tx,
|
&working_event_tx,
|
||||||
new_status,
|
new_status,
|
||||||
|
finished_submission_request_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if shutdown {
|
if shutdown {
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ fn workspace_worker_create_request(
|
|||||||
relative_cwd: attachment.relative_cwd,
|
relative_cwd: attachment.relative_cwd,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
control_operation_id: Some(control_operation_id),
|
control_operation_id: Some(control_operation_id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ fn send_internal_worker_state(
|
|||||||
let _ = event_tx.send(Event::WorkerState {
|
let _ = event_tx.send(Event::WorkerState {
|
||||||
snapshot: protocol::WorkerStateSnapshot {
|
snapshot: protocol::WorkerStateSnapshot {
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state,
|
state,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -521,6 +522,19 @@ impl InternalWorkerSessionHandle {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn session_attachment(
|
||||||
|
&self,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Result<session_store::RetainedSessionAttachment, session_store::RetainedAttachmentReadError>
|
||||||
|
{
|
||||||
|
session_store::read_session_attachment_from_entries(
|
||||||
|
&self.entries(),
|
||||||
|
attachment_id,
|
||||||
|
max_attachment_bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn send(
|
pub(crate) async fn send(
|
||||||
&self,
|
&self,
|
||||||
input: impl Into<String>,
|
input: impl Into<String>,
|
||||||
@@ -1447,6 +1461,50 @@ permission = "write"
|
|||||||
assert_eq!(result.identity.kind, "test");
|
assert_eq!(result.identity.kind, "test");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn visible_internal_session_resolves_its_projected_image_attachment() {
|
||||||
|
let (handle, _events) =
|
||||||
|
test_internal_worker_session(InternalWorkerVisibility::ParentClient);
|
||||||
|
let image = vec![1, 3, 5, 7];
|
||||||
|
let entry_id = "internal-image-entry";
|
||||||
|
let attachment_id =
|
||||||
|
session_store::session_tool_attachment_id(entry_id, 0, "image/png", &image);
|
||||||
|
handle.publish_test_entry(LogEntry::AnnotatedToolResult {
|
||||||
|
ts: 1,
|
||||||
|
entry: session_store::LoggedHistoryEntry {
|
||||||
|
item: session_store::LoggedItem::ToolResult {
|
||||||
|
call_id: "internal-image-call".into(),
|
||||||
|
summary: "internal screenshot".into(),
|
||||||
|
content: None,
|
||||||
|
attachments: vec![session_store::logged_item::LoggedAttachment::Image {
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
data: image.clone(),
|
||||||
|
}],
|
||||||
|
disposition: agen::tool::ToolResultDisposition::Success,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()),
|
||||||
|
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let projected = handle.protocol_snapshot();
|
||||||
|
let protocol::SessionSnapshotEntryData::ToolResult { attachments, .. } =
|
||||||
|
&projected.session.entries[0].data
|
||||||
|
else {
|
||||||
|
panic!("expected projected tool result");
|
||||||
|
};
|
||||||
|
assert_eq!(attachments[0].attachment_id, attachment_id);
|
||||||
|
let attachment = handle
|
||||||
|
.session_attachment(&attachment_id, 1024)
|
||||||
|
.expect("internal image body");
|
||||||
|
assert_eq!(attachment.media_type, "image/png");
|
||||||
|
assert_eq!(attachment.data, image);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn internal_turn_result_mapping_is_exhaustive() {
|
fn internal_turn_result_mapping_is_exhaustive() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ impl WorkerSharedState {
|
|||||||
snapshot.clone()
|
snapshot.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish the post-Run state and the exact Submit request whose controller
|
||||||
|
/// invocation has returned. Updating both in one snapshot prevents observers
|
||||||
|
/// from treating an earlier Idle state as evidence for a later Run.
|
||||||
|
pub(crate) fn finish_submission_run(
|
||||||
|
&self,
|
||||||
|
state: WorkerState,
|
||||||
|
submission_request_id: String,
|
||||||
|
) -> WorkerStateSnapshot {
|
||||||
|
let mut snapshot = self
|
||||||
|
.state
|
||||||
|
.write()
|
||||||
|
.expect("worker state lock poisoned; refusing an inferred fallback state");
|
||||||
|
snapshot.state = state;
|
||||||
|
snapshot.last_finished_submission_request_id = Some(submission_request_id);
|
||||||
|
snapshot.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn admit_command(
|
pub(crate) fn admit_command(
|
||||||
&self,
|
&self,
|
||||||
envelope: WorkerCommandEnvelope,
|
envelope: WorkerCommandEnvelope,
|
||||||
@@ -305,6 +322,14 @@ mod tests {
|
|||||||
assert_eq!(snapshot.state, paused);
|
assert_eq!(snapshot.state, paused);
|
||||||
assert_eq!(snapshot.last_command_id, 0);
|
assert_eq!(snapshot.last_command_id, 0);
|
||||||
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
|
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
|
||||||
|
|
||||||
|
let finished = state.finish_submission_run(WorkerState::Idle, "request-1".to_string());
|
||||||
|
assert_eq!(finished.state, WorkerState::Idle);
|
||||||
|
assert_eq!(
|
||||||
|
finished.last_finished_submission_request_id.as_deref(),
|
||||||
|
Some("request-1")
|
||||||
|
);
|
||||||
|
assert_eq!(finished.last_command_id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -319,6 +344,7 @@ mod tests {
|
|||||||
state.snapshot(),
|
state.snapshot(),
|
||||||
WorkerStateSnapshot {
|
WorkerStateSnapshot {
|
||||||
last_command_id: 9,
|
last_command_id: 9,
|
||||||
|
last_finished_submission_request_id: None,
|
||||||
state: WorkerState::Idle,
|
state: WorkerState::Idle,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -774,6 +774,41 @@ impl SpawnedWorkerRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn session_attachment(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
max_attachment_bytes: u64,
|
||||||
|
) -> Option<
|
||||||
|
Result<
|
||||||
|
session_store::RetainedSessionAttachment,
|
||||||
|
session_store::RetainedAttachmentReadError,
|
||||||
|
>,
|
||||||
|
> {
|
||||||
|
let internal_session = self
|
||||||
|
.internal_records
|
||||||
|
.lock()
|
||||||
|
.ok()?
|
||||||
|
.iter()
|
||||||
|
.find(|record| {
|
||||||
|
record.session.visibility() == InternalWorkerVisibility::ParentClient
|
||||||
|
&& record.session.session_id_string() == session_id
|
||||||
|
})
|
||||||
|
.map(|record| record.session.clone());
|
||||||
|
let session = internal_session.or_else(|| {
|
||||||
|
self.service_records
|
||||||
|
.lock()
|
||||||
|
.ok()?
|
||||||
|
.iter()
|
||||||
|
.find(|record| {
|
||||||
|
record.session.visibility() == InternalWorkerVisibility::ParentClient
|
||||||
|
&& record.session.session_id_string() == session_id
|
||||||
|
})
|
||||||
|
.map(|record| record.session.clone())
|
||||||
|
})?;
|
||||||
|
Some(session.session_attachment(attachment_id, max_attachment_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn internal_worker_snapshots(&self) -> Vec<InternalWorkerSnapshot> {
|
pub(crate) fn internal_worker_snapshots(&self) -> Vec<InternalWorkerSnapshot> {
|
||||||
let parent_session_id = self
|
let parent_session_id = self
|
||||||
.parent_protocol
|
.parent_protocol
|
||||||
@@ -1258,6 +1293,59 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parent_visible_internal_attachment_resolves_by_session_identity() {
|
||||||
|
let registry = registry();
|
||||||
|
let (visible, _visible_tx) =
|
||||||
|
record("visible", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
let session_id = visible.session.session_id_string();
|
||||||
|
let image = vec![2, 4, 6, 8];
|
||||||
|
let entry_id = "visible-image-entry";
|
||||||
|
let attachment_id =
|
||||||
|
session_store::session_tool_attachment_id(entry_id, 0, "image/png", &image);
|
||||||
|
visible
|
||||||
|
.session
|
||||||
|
.publish_test_entry(LogEntry::AnnotatedToolResult {
|
||||||
|
ts: 1,
|
||||||
|
entry: session_store::LoggedHistoryEntry {
|
||||||
|
item: session_store::LoggedItem::ToolResult {
|
||||||
|
call_id: "visible-image-call".into(),
|
||||||
|
summary: "visible image".into(),
|
||||||
|
content: None,
|
||||||
|
attachments: vec![session_store::logged_item::LoggedAttachment::Image {
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
data: image.clone(),
|
||||||
|
}],
|
||||||
|
disposition: agen::tool::ToolResultDisposition::Success,
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()),
|
||||||
|
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registry.internal_records.lock().unwrap().push(visible);
|
||||||
|
|
||||||
|
let attachment = registry
|
||||||
|
.session_attachment(&session_id, &attachment_id, 1024)
|
||||||
|
.expect("visible session")
|
||||||
|
.expect("visible attachment");
|
||||||
|
assert_eq!(attachment.data, image);
|
||||||
|
|
||||||
|
let (private, _private_tx) =
|
||||||
|
record("private", InternalWorkerVisibility::ServicePrivate).await;
|
||||||
|
let private_session_id = private.session.session_id_string();
|
||||||
|
registry.internal_records.lock().unwrap().push(private);
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.session_attachment(&private_session_id, &attachment_id, 1024)
|
||||||
|
.is_none(),
|
||||||
|
"service-private sessions must not become parent-fetchable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn visible_internal_worker_starts_with_own_live_snapshot_metadata() {
|
async fn visible_internal_worker_starts_with_own_live_snapshot_metadata() {
|
||||||
let registry = registry();
|
let registry = registry();
|
||||||
|
|||||||
@@ -1299,12 +1299,10 @@ async fn run_end_returns_to_idle_without_busy_status() {
|
|||||||
let worker = make_worker(client).await;
|
let worker = make_worker(client).await;
|
||||||
let handle = spawn_controller(worker).await;
|
let handle = spawn_controller(worker).await;
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
let submission_request_id = protocol::new_submission_request_id();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::submit_text(
|
.send(Method::submit_text(submission_request_id.clone(), "Hello"))
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"Hello",
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1319,7 +1317,11 @@ async fn run_end_returns_to_idle_without_busy_status() {
|
|||||||
saw_run_end = true;
|
saw_run_end = true;
|
||||||
}
|
}
|
||||||
Ok(Event::WorkerState { snapshot })
|
Ok(Event::WorkerState { snapshot })
|
||||||
if saw_run_end && snapshot.catalog_status() == WorkerStatus::Idle => {
|
if saw_run_end
|
||||||
|
&& snapshot.catalog_status() == WorkerStatus::Idle
|
||||||
|
&& snapshot.last_finished_submission_request_id.as_deref()
|
||||||
|
== Some(submission_request_id.as_str()) =>
|
||||||
|
{
|
||||||
saw_idle_status = true;
|
saw_idle_status = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1334,7 +1336,7 @@ async fn run_end_returns_to_idle_without_busy_status() {
|
|||||||
assert!(saw_run_end, "expected RunEnd::Finished");
|
assert!(saw_run_end, "expected RunEnd::Finished");
|
||||||
assert!(
|
assert!(
|
||||||
saw_idle_status,
|
saw_idle_status,
|
||||||
"expected idle status immediately after RunEnd"
|
"expected exact Submit teardown fence in idle status immediately after RunEnd"
|
||||||
);
|
);
|
||||||
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
|
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
|||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
workdir = { workspace = true, features = ["http-client"] }
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
worker-runtime.workspace = true
|
worker-runtime.workspace = true
|
||||||
|
session-store.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
@@ -51,7 +52,6 @@ webauthn-rs = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
session-store.workspace = true
|
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tools.workspace = true
|
tools.workspace = true
|
||||||
yoi = { path = "../yoi" }
|
yoi = { path = "../yoi" }
|
||||||
|
|||||||
@@ -284,6 +284,38 @@ pub struct BackendJobRecord {
|
|||||||
pub completed_at: Option<String>,
|
pub completed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BackendJobWorkerCleanupState {
|
||||||
|
Pending,
|
||||||
|
Executing,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendJobWorkerCleanupState {
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Executing => "executing",
|
||||||
|
Self::Completed => "completed",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||||
|
match value {
|
||||||
|
"pending" => Ok(Self::Pending),
|
||||||
|
"executing" => Ok(Self::Executing),
|
||||||
|
"completed" => Ok(Self::Completed),
|
||||||
|
"failed" => Ok(Self::Failed),
|
||||||
|
other => Err(Error::Store(format!(
|
||||||
|
"unknown Backend Job Worker cleanup state `{other}`"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct BackendJobAttemptRecord {
|
pub struct BackendJobAttemptRecord {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -300,6 +332,11 @@ pub struct BackendJobAttemptRecord {
|
|||||||
pub result_digest: Option<String>,
|
pub result_digest: Option<String>,
|
||||||
pub failure_category: Option<String>,
|
pub failure_category: Option<String>,
|
||||||
pub failure_detail: Option<String>,
|
pub failure_detail: Option<String>,
|
||||||
|
pub worker_cleanup_state: Option<BackendJobWorkerCleanupState>,
|
||||||
|
pub worker_cleanup_failure_category: Option<String>,
|
||||||
|
pub worker_cleanup_failure_detail: Option<String>,
|
||||||
|
pub worker_cleanup_updated_at: Option<String>,
|
||||||
|
pub worker_cleanup_completed_at: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub completed_at: Option<String>,
|
pub completed_at: Option<String>,
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ pub struct InternalWorkerSummary {
|
|||||||
pub availability: protocol::subscription::SubscriptionWorkerAvailability,
|
pub availability: protocol::subscription::SubscriptionWorkerAvailability,
|
||||||
/// Runtime catalog lifecycle compatibility state.
|
/// Runtime catalog lifecycle compatibility state.
|
||||||
pub state: String,
|
pub state: String,
|
||||||
|
/// Runtime-process reconstruction evidence used only for lifecycle coordination.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub execution_reconstructed: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||||
pub last_seen_at: Option<String>,
|
pub last_seen_at: Option<String>,
|
||||||
@@ -770,6 +773,12 @@ pub struct WorkerProxyConnectPoint {
|
|||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeSessionAttachment {
|
||||||
|
pub media_type: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RuntimeRegistryError {
|
pub enum RuntimeRegistryError {
|
||||||
InvalidIdentifier {
|
InvalidIdentifier {
|
||||||
@@ -1220,6 +1229,23 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
_workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
_session_id: &str,
|
||||||
|
_attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id().to_string(),
|
||||||
|
code: "session_attachment_unsupported".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Session attachment reads are not supported for Worker `{}`",
|
||||||
|
worker_ref.worker_id
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
_request: runtime_api::SessionPublicSearchRequest,
|
_request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -2096,6 +2122,27 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
|
||||||
|
validate_backend_identifier("worker_id", &worker.worker_id)?;
|
||||||
|
validate_backend_identifier("session_id", session_id)?;
|
||||||
|
validate_backend_identifier("attachment_id", attachment_id)?;
|
||||||
|
let runtime = self.runtime(&worker.runtime_id)?;
|
||||||
|
let worker_ref =
|
||||||
|
EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else(
|
||||||
|
|| RuntimeRegistryError::UnknownWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
},
|
||||||
|
)?);
|
||||||
|
runtime.worker_session_attachment(workspace_id, &worker_ref, session_id, attachment_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_public_search(
|
pub fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -2333,6 +2380,7 @@ impl EmbeddedWorkerRuntime {
|
|||||||
summary.execution_metadata_available,
|
summary.execution_metadata_available,
|
||||||
)
|
)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
execution_reconstructed: false,
|
||||||
worker_state: summary.worker_state.clone(),
|
worker_state: summary.worker_state.clone(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -2373,6 +2421,7 @@ impl EmbeddedWorkerRuntime {
|
|||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
|
state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
execution_reconstructed: detail.execution_reconstructed,
|
||||||
worker_state: detail.worker_state.clone(),
|
worker_state: detail.worker_state.clone(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -3017,6 +3066,46 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
|
||||||
|
self.runtime
|
||||||
|
.worker_session_attachment_scoped(
|
||||||
|
&scope,
|
||||||
|
worker_ref,
|
||||||
|
session_id.to_string(),
|
||||||
|
attachment_id.to_string(),
|
||||||
|
)
|
||||||
|
.map(|attachment| RuntimeSessionAttachment {
|
||||||
|
media_type: attachment.media_type,
|
||||||
|
data: attachment.data,
|
||||||
|
})
|
||||||
|
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: match error {
|
||||||
|
session_store::RetainedAttachmentReadError::RetentionMissing
|
||||||
|
| session_store::RetainedAttachmentReadError::ActivePointerMissing => {
|
||||||
|
"session_attachment_expired"
|
||||||
|
}
|
||||||
|
session_store::RetainedAttachmentReadError::SessionMismatch
|
||||||
|
| session_store::RetainedAttachmentReadError::NotFound => {
|
||||||
|
"session_attachment_not_found"
|
||||||
|
}
|
||||||
|
session_store::RetainedAttachmentReadError::ResourceLimit => {
|
||||||
|
"session_attachment_too_large"
|
||||||
|
}
|
||||||
|
_ => "session_attachment_unavailable",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
request: runtime_api::SessionPublicSearchRequest,
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -3732,7 +3821,7 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
|||||||
}
|
}
|
||||||
if path.ends_with("/input")
|
if path.ends_with("/input")
|
||||||
|| path.ends_with("/restore")
|
|| path.ends_with("/restore")
|
||||||
|| path.contains("/attachments")
|
|| (path.contains("/attachments") && method != "GET")
|
||||||
|| path.contains("/workdir-attachments")
|
|| path.contains("/workdir-attachments")
|
||||||
{
|
{
|
||||||
return "workers:input";
|
return "workers:input";
|
||||||
@@ -4059,6 +4148,64 @@ impl RemoteWorkerRuntime {
|
|||||||
self.send_json(path, "GET", &[], self.http.get(self.endpoint(path)))
|
self.send_json(path, "GET", &[], self.http.get(self.endpoint(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_session_attachment(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeDiagnostic> {
|
||||||
|
let runtime_id = self.runtime_id.clone();
|
||||||
|
let workspace_id = self.workspace_id.clone();
|
||||||
|
let bearer_token = self.bearer_token.clone();
|
||||||
|
let capability_token = match &self.workspace_authorization {
|
||||||
|
Some(authorization) => Some(authorization.issue(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
workspace_runtime_operation("GET", path),
|
||||||
|
worker_id_from_remote_path(path).as_deref(),
|
||||||
|
&[],
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let request = self.http.get(self.endpoint(path));
|
||||||
|
run_blocking_http(move || {
|
||||||
|
let mut request = request.header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id);
|
||||||
|
if let Some(token) = capability_token.as_deref().or(bearer_token.as_deref()) {
|
||||||
|
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.map_err(|error| remote_reqwest_diagnostic(&runtime_id, error))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(remote_http_status_diagnostic(&runtime_id, status, response));
|
||||||
|
}
|
||||||
|
let media_type = response
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
response
|
||||||
|
.take((MAX_REMOTE_RUNTIME_RESPONSE_BYTES + 1) as u64)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|_| {
|
||||||
|
diagnostic(
|
||||||
|
"remote_runtime_response_read_failed",
|
||||||
|
HostDiagnosticSeverity::Error,
|
||||||
|
"Remote Runtime attachment response could not be read".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if data.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES {
|
||||||
|
return Err(diagnostic(
|
||||||
|
"remote_runtime_response_too_large",
|
||||||
|
HostDiagnosticSeverity::Error,
|
||||||
|
"Remote Runtime attachment response exceeded the allowed size".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(RuntimeSessionAttachment { media_type, data })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
@@ -4226,6 +4373,7 @@ impl RemoteWorkerRuntime {
|
|||||||
summary.execution_metadata_available,
|
summary.execution_metadata_available,
|
||||||
)
|
)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
execution_reconstructed: false,
|
||||||
worker_state: summary.worker_state.clone(),
|
worker_state: summary.worker_state.clone(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -4264,6 +4412,7 @@ impl RemoteWorkerRuntime {
|
|||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
|
state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
execution_reconstructed: detail.execution_reconstructed,
|
||||||
worker_state: detail.worker_state.clone(),
|
worker_state: detail.worker_state.clone(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -5093,6 +5242,29 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worker_session_attachment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_ref: &EmbeddedWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<RuntimeSessionAttachment, RuntimeRegistryError> {
|
||||||
|
let path = format!(
|
||||||
|
"/v1/workers/{}/sessions/{}/attachments/{}?workspace_id={}",
|
||||||
|
url_path_segment_encode(&worker_ref.worker_id.to_string()),
|
||||||
|
url_path_segment_encode(session_id),
|
||||||
|
url_path_segment_encode(attachment_id),
|
||||||
|
url_query_value_encode(workspace_id),
|
||||||
|
);
|
||||||
|
self.get_session_attachment(&path).map_err(|diagnostic| {
|
||||||
|
RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
code: diagnostic.code,
|
||||||
|
message: diagnostic.message,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn session_public_search(
|
fn session_public_search(
|
||||||
&self,
|
&self,
|
||||||
request: runtime_api::SessionPublicSearchRequest,
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
@@ -6106,6 +6278,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> InternalWorkerSummary {
|
|||||||
},
|
},
|
||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
||||||
state: "unsupported".to_string(),
|
state: "unsupported".to_string(),
|
||||||
|
execution_reconstructed: false,
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -6834,6 +7007,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||||
state: "available".to_string(),
|
state: "available".to_string(),
|
||||||
|
execution_reconstructed: false,
|
||||||
worker_state: None,
|
worker_state: None,
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -7648,6 +7822,13 @@ mod tests {
|
|||||||
),
|
),
|
||||||
"workers:input"
|
"workers:input"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
workspace_runtime_operation(
|
||||||
|
"GET",
|
||||||
|
&format!("/v1/workers/{worker_id}/sessions/session-1/attachments/attachment-1")
|
||||||
|
),
|
||||||
|
"workers:read"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH),
|
workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH),
|
||||||
SSH_HOST_KEY_PROBE_OPERATION
|
SSH_HOST_KEY_PROBE_OPERATION
|
||||||
|
|||||||
@@ -875,6 +875,13 @@ CREATE TABLE backend_job_attempts (
|
|||||||
result_digest TEXT,
|
result_digest TEXT,
|
||||||
failure_category TEXT,
|
failure_category TEXT,
|
||||||
failure_detail TEXT,
|
failure_detail TEXT,
|
||||||
|
worker_cleanup_state TEXT CHECK (
|
||||||
|
worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed')
|
||||||
|
),
|
||||||
|
worker_cleanup_failure_category TEXT,
|
||||||
|
worker_cleanup_failure_detail TEXT,
|
||||||
|
worker_cleanup_updated_at TEXT,
|
||||||
|
worker_cleanup_completed_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
@@ -889,6 +896,8 @@ CREATE TABLE backend_job_attempts (
|
|||||||
);
|
);
|
||||||
CREATE INDEX backend_job_attempts_recovery
|
CREATE INDEX backend_job_attempts_recovery
|
||||||
ON backend_job_attempts(workspace_id, state, deadline_at);
|
ON backend_job_attempts(workspace_id, state, deadline_at);
|
||||||
|
CREATE INDEX backend_job_attempts_worker_cleanup
|
||||||
|
ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at);
|
||||||
CREATE TABLE backend_job_deliveries (
|
CREATE TABLE backend_job_deliveries (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
delivery_id TEXT NOT NULL,
|
delivery_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -447,6 +447,23 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
|
|||||||
Ok(Some(bundle))
|
Ok(Some(bundle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_profile_manifest_from_config_bundle(
|
||||||
|
bundle: &ConfigBundle,
|
||||||
|
selector: &str,
|
||||||
|
) -> Result<manifest::WorkerManifest> {
|
||||||
|
let archive = bundle.profile_source_archive.as_ref().ok_or_else(|| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"resolved Profile {selector:?} is missing its source archive"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let verified = archive.verify().map_err(|error| {
|
||||||
|
Error::Config(format!("failed to verify Profile {selector:?}: {error}"))
|
||||||
|
})?;
|
||||||
|
verified
|
||||||
|
.resolve_profile(selector, Path::new("/"), "worker-launch-validation")
|
||||||
|
.map_err(|error| Error::Config(format!("failed to resolve Profile {selector:?}: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_virtual_profile_archive(
|
fn build_virtual_profile_archive(
|
||||||
selector: &str,
|
selector: &str,
|
||||||
entry: &VirtualProfileEntry,
|
entry: &VirtualProfileEntry,
|
||||||
|
|||||||
+1298
-172
File diff suppressed because it is too large
Load Diff
@@ -21,13 +21,14 @@ use crate::backend_job::{
|
|||||||
BackendJobAttemptRecord, BackendJobAttemptState, BackendJobDeliveryRecord,
|
BackendJobAttemptRecord, BackendJobAttemptState, BackendJobDeliveryRecord,
|
||||||
BackendJobDeliveryState, BackendJobRecord, BackendJobRequest, BackendJobReservation,
|
BackendJobDeliveryState, BackendJobRecord, BackendJobRequest, BackendJobReservation,
|
||||||
BackendJobResultAcceptance, BackendJobResultSubmission, BackendJobState,
|
BackendJobResultAcceptance, BackendJobResultSubmission, BackendJobState,
|
||||||
BackendJobWorkerBinding, attempt_id, bounded_failure_detail, result_digest,
|
BackendJobWorkerBinding, BackendJobWorkerCleanupState, attempt_id, bounded_failure_detail,
|
||||||
|
result_digest,
|
||||||
};
|
};
|
||||||
use crate::workspace_deletion::WorkspaceDeletionStore;
|
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 76;
|
const LATEST_SCHEMA_VERSION: i64 = 77;
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||||
@@ -74,6 +75,8 @@ const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
|
|||||||
"Runtime-scoped durable Worker catalog identity";
|
"Runtime-scoped durable Worker catalog identity";
|
||||||
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
||||||
"preserve explicit observe grants for committed Worker Session archives";
|
"preserve explicit observe grants for committed Worker Session archives";
|
||||||
|
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
||||||
|
"durable terminal Backend Job Worker cleanup";
|
||||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||||
@@ -251,6 +254,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
|
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
|
||||||
apply: migrate_archive_observe_grants_v75_to_v76,
|
apply: migrate_archive_observe_grants_v75_to_v76,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 77,
|
||||||
|
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
||||||
|
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -1592,6 +1600,34 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
failure_detail: &str,
|
failure_detail: &str,
|
||||||
now: &str,
|
now: &str,
|
||||||
) -> Result<BackendJobAttemptRecord>;
|
) -> Result<BackendJobAttemptRecord>;
|
||||||
|
fn recover_backend_job_worker_cleanups(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
now: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<BackendJobAttemptRecord>>;
|
||||||
|
fn list_backend_job_worker_cleanups(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<BackendJobAttemptRecord>>;
|
||||||
|
fn claim_backend_job_worker_cleanup(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
job_id: &str,
|
||||||
|
attempt_id: &str,
|
||||||
|
now: &str,
|
||||||
|
) -> Result<(BackendJobAttemptRecord, bool)>;
|
||||||
|
fn finish_backend_job_worker_cleanup(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
job_id: &str,
|
||||||
|
attempt_id: &str,
|
||||||
|
state: BackendJobWorkerCleanupState,
|
||||||
|
failure_category: Option<&str>,
|
||||||
|
failure_detail: Option<&str>,
|
||||||
|
now: &str,
|
||||||
|
) -> Result<BackendJobAttemptRecord>;
|
||||||
fn reserve_backend_job_delivery(
|
fn reserve_backend_job_delivery(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -6648,7 +6684,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let attempt_changed = tx.execute(
|
let attempt_changed = tx.execute(
|
||||||
"UPDATE backend_job_attempts SET state = 'completed', result_json = ?4, result_digest = ?5, failure_category = NULL, failure_detail = NULL, completed_at = ?6, updated_at = ?6 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state = 'dispatched'",
|
"UPDATE backend_job_attempts SET state = 'completed', result_json = ?4, result_digest = ?5, failure_category = NULL, failure_detail = NULL, worker_cleanup_state = 'pending', worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?6, worker_cleanup_completed_at = NULL, completed_at = ?6, updated_at = ?6 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state = 'dispatched'",
|
||||||
params![workspace_id, submission.job_id, submission.attempt_id, result_json, digest, now],
|
params![workspace_id, submission.job_id, submission.attempt_id, result_json, digest, now],
|
||||||
)?;
|
)?;
|
||||||
let job_changed = tx.execute(
|
let job_changed = tx.execute(
|
||||||
@@ -6707,7 +6743,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
return Ok(attempt);
|
return Ok(attempt);
|
||||||
}
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE backend_job_attempts SET state = ?4, failure_category = ?5, failure_detail = ?6, completed_at = ?7, updated_at = ?7 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state IN ('reserved', 'dispatching', 'dispatched')",
|
"UPDATE backend_job_attempts SET state = ?4, failure_category = ?5, failure_detail = ?6, worker_cleanup_state = CASE WHEN worker_id IS NULL THEN 'completed' ELSE 'pending' END, worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?7, worker_cleanup_completed_at = CASE WHEN worker_id IS NULL THEN ?7 ELSE NULL END, completed_at = ?7, updated_at = ?7 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND state IN ('reserved', 'dispatching', 'dispatched')",
|
||||||
params![workspace_id, job_id, attempt_id, state.as_str(), failure_category, detail, now],
|
params![workspace_id, job_id, attempt_id, state.as_str(), failure_category, detail, now],
|
||||||
)?;
|
)?;
|
||||||
if attempt.attempt == job.current_attempt && job.state == BackendJobState::Pending {
|
if attempt.attempt == job.current_attempt && job.state == BackendJobState::Pending {
|
||||||
@@ -6728,6 +6764,154 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn recover_backend_job_worker_cleanups(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
now: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<BackendJobAttemptRecord>> {
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE backend_job_attempts SET worker_cleanup_state = 'pending', worker_cleanup_failure_category = 'cleanup_interrupted', worker_cleanup_failure_detail = 'Backend restart interrupted Worker cleanup; retrying canonical removal', worker_cleanup_updated_at = ?2 WHERE workspace_id = ?1 AND worker_cleanup_state = 'executing'",
|
||||||
|
params![workspace_id, now],
|
||||||
|
)?;
|
||||||
|
let mut statement = tx.prepare(
|
||||||
|
"SELECT job_id, attempt_id FROM backend_job_attempts WHERE workspace_id = ?1 AND state IN ('completed', 'failed', 'unknown') AND runtime_id IS NOT NULL AND worker_cleanup_state IN ('pending', 'failed') ORDER BY worker_cleanup_updated_at, created_at LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let identities = statement
|
||||||
|
.query_map(params![workspace_id, limit as i64], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
drop(statement);
|
||||||
|
let attempts = identities
|
||||||
|
.into_iter()
|
||||||
|
.map(|(job_id, attempt_id)| {
|
||||||
|
read_backend_job_attempt(&tx, workspace_id, &job_id, &attempt_id)?.ok_or_else(
|
||||||
|
|| Error::Store("recoverable Backend Job Worker cleanup disappeared".to_string()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(attempts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_backend_job_worker_cleanups(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<BackendJobAttemptRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT job_id, attempt_id FROM backend_job_attempts WHERE workspace_id = ?1 AND state IN ('completed', 'failed', 'unknown') AND runtime_id IS NOT NULL AND worker_cleanup_state IN ('pending', 'failed') ORDER BY worker_cleanup_updated_at, created_at LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let identities = statement
|
||||||
|
.query_map(params![workspace_id, limit as i64], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
identities
|
||||||
|
.into_iter()
|
||||||
|
.map(|(job_id, attempt_id)| {
|
||||||
|
read_backend_job_attempt(conn, workspace_id, &job_id, &attempt_id)?.ok_or_else(
|
||||||
|
|| Error::Store("recoverable Backend Job Worker cleanup disappeared".to_string()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claim_backend_job_worker_cleanup(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
job_id: &str,
|
||||||
|
attempt_id: &str,
|
||||||
|
now: &str,
|
||||||
|
) -> Result<(BackendJobAttemptRecord, bool)> {
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||||
|
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
|
||||||
|
if !attempt.state.terminal() || attempt.worker.is_none() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Backend Job Worker cleanup requires a terminal bound attempt".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
attempt.worker_cleanup_state,
|
||||||
|
Some(BackendJobWorkerCleanupState::Pending | BackendJobWorkerCleanupState::Failed)
|
||||||
|
) {
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok((attempt, false));
|
||||||
|
}
|
||||||
|
let changed = tx.execute(
|
||||||
|
"UPDATE backend_job_attempts SET worker_cleanup_state = 'executing', worker_cleanup_failure_category = NULL, worker_cleanup_failure_detail = NULL, worker_cleanup_updated_at = ?4 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND worker_cleanup_state IN ('pending', 'failed')",
|
||||||
|
params![workspace_id, job_id, attempt_id, now],
|
||||||
|
)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"Backend Job Worker cleanup claim compare-and-set failed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||||
|
.ok_or_else(|| Error::Store("claimed Backend Job Worker cleanup disappeared".to_string()))?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok((attempt, true))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_backend_job_worker_cleanup(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
job_id: &str,
|
||||||
|
attempt_id: &str,
|
||||||
|
state: BackendJobWorkerCleanupState,
|
||||||
|
failure_category: Option<&str>,
|
||||||
|
failure_detail: Option<&str>,
|
||||||
|
now: &str,
|
||||||
|
) -> Result<BackendJobAttemptRecord> {
|
||||||
|
if !matches!(
|
||||||
|
state,
|
||||||
|
BackendJobWorkerCleanupState::Completed | BackendJobWorkerCleanupState::Failed
|
||||||
|
) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Backend Job Worker cleanup completion requires completed or failed state"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let failure_detail = failure_detail.map(bounded_failure_detail);
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let current = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||||
|
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
|
||||||
|
if current.worker_cleanup_state == Some(state) {
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok(current);
|
||||||
|
}
|
||||||
|
if current.worker_cleanup_state != Some(BackendJobWorkerCleanupState::Executing) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Backend Job Worker cleanup is not executing".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let completed_at = (state == BackendJobWorkerCleanupState::Completed).then_some(now);
|
||||||
|
let changed = tx.execute(
|
||||||
|
"UPDATE backend_job_attempts SET worker_cleanup_state = ?4, worker_cleanup_failure_category = ?5, worker_cleanup_failure_detail = ?6, worker_cleanup_updated_at = ?7, worker_cleanup_completed_at = ?8 WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3 AND worker_cleanup_state = 'executing'",
|
||||||
|
params![workspace_id, job_id, attempt_id, state.as_str(), failure_category, failure_detail, now, completed_at],
|
||||||
|
)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"Backend Job Worker cleanup completion compare-and-set failed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attempt = read_backend_job_attempt(&tx, workspace_id, job_id, attempt_id)?
|
||||||
|
.ok_or_else(|| Error::Store("finished Backend Job Worker cleanup disappeared".to_string()))?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(attempt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn reserve_backend_job_delivery(
|
fn reserve_backend_job_delivery(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -10457,14 +10641,16 @@ fn read_backend_job_attempt(
|
|||||||
attempt_id: &str,
|
attempt_id: &str,
|
||||||
) -> Result<Option<BackendJobAttemptRecord>> {
|
) -> Result<Option<BackendJobAttemptRecord>> {
|
||||||
let row = conn.query_row(
|
let row = conn.query_row(
|
||||||
"SELECT attempt, input_revision, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3",
|
"SELECT attempt, input_revision, state, runtime_id, worker_id, runtime_run_id, dispatched_at, deadline_at, result_json, result_digest, failure_category, failure_detail, worker_cleanup_state, worker_cleanup_failure_category, worker_cleanup_failure_detail, worker_cleanup_updated_at, worker_cleanup_completed_at, created_at, updated_at, completed_at FROM backend_job_attempts WHERE workspace_id = ?1 AND job_id = ?2 AND attempt_id = ?3",
|
||||||
params![workspace_id, job_id, attempt_id],
|
params![workspace_id, job_id, attempt_id],
|
||||||
|row| Ok((
|
|row| Ok((
|
||||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
|
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
|
||||||
row.get::<_, Option<String>>(3)?, row.get::<_, Option<String>>(4)?, row.get::<_, Option<String>>(5)?,
|
row.get::<_, Option<String>>(3)?, row.get::<_, Option<String>>(4)?, row.get::<_, Option<String>>(5)?,
|
||||||
row.get::<_, Option<String>>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?,
|
row.get::<_, Option<String>>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?,
|
||||||
row.get::<_, Option<String>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?,
|
row.get::<_, Option<String>>(9)?, row.get::<_, Option<String>>(10)?, row.get::<_, Option<String>>(11)?,
|
||||||
row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, Option<String>>(14)?,
|
row.get::<_, Option<String>>(12)?, row.get::<_, Option<String>>(13)?, row.get::<_, Option<String>>(14)?,
|
||||||
|
row.get::<_, Option<String>>(15)?, row.get::<_, Option<String>>(16)?, row.get::<_, String>(17)?,
|
||||||
|
row.get::<_, String>(18)?, row.get::<_, Option<String>>(19)?,
|
||||||
)),
|
)),
|
||||||
).optional()?;
|
).optional()?;
|
||||||
let Some((
|
let Some((
|
||||||
@@ -10480,6 +10666,11 @@ fn read_backend_job_attempt(
|
|||||||
result_digest,
|
result_digest,
|
||||||
failure_category,
|
failure_category,
|
||||||
failure_detail,
|
failure_detail,
|
||||||
|
worker_cleanup_state,
|
||||||
|
worker_cleanup_failure_category,
|
||||||
|
worker_cleanup_failure_detail,
|
||||||
|
worker_cleanup_updated_at,
|
||||||
|
worker_cleanup_completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
completed_at,
|
completed_at,
|
||||||
@@ -10521,6 +10712,14 @@ fn read_backend_job_attempt(
|
|||||||
result_digest,
|
result_digest,
|
||||||
failure_category,
|
failure_category,
|
||||||
failure_detail,
|
failure_detail,
|
||||||
|
worker_cleanup_state: worker_cleanup_state
|
||||||
|
.as_deref()
|
||||||
|
.map(BackendJobWorkerCleanupState::parse)
|
||||||
|
.transpose()?,
|
||||||
|
worker_cleanup_failure_category,
|
||||||
|
worker_cleanup_failure_detail,
|
||||||
|
worker_cleanup_updated_at,
|
||||||
|
worker_cleanup_completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
completed_at,
|
completed_at,
|
||||||
@@ -14248,6 +14447,45 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()> {
|
||||||
|
let current = current_schema_version(conn)?;
|
||||||
|
if current != 76 {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"expected schema version 76 before {BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME} migration, found {current}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
|
tx.execute_batch(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_state TEXT
|
||||||
|
CHECK (worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed'));
|
||||||
|
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_category TEXT;
|
||||||
|
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_failure_detail TEXT;
|
||||||
|
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_updated_at TEXT;
|
||||||
|
ALTER TABLE backend_job_attempts ADD COLUMN worker_cleanup_completed_at TEXT;
|
||||||
|
UPDATE backend_job_attempts
|
||||||
|
SET worker_cleanup_state = CASE
|
||||||
|
WHEN runtime_id IS NULL THEN 'completed'
|
||||||
|
ELSE 'pending'
|
||||||
|
END,
|
||||||
|
worker_cleanup_updated_at = updated_at,
|
||||||
|
worker_cleanup_completed_at = CASE
|
||||||
|
WHEN runtime_id IS NULL THEN updated_at
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE state IN ('completed', 'failed', 'unknown');
|
||||||
|
CREATE INDEX backend_job_attempts_worker_cleanup
|
||||||
|
ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
|
params![77_i64, BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||||
@@ -16810,6 +17048,10 @@ mod tests {
|
|||||||
version: 76,
|
version: 76,
|
||||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
|
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 77,
|
||||||
|
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -16893,6 +17135,10 @@ mod tests {
|
|||||||
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||||
),
|
),
|
||||||
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
|
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||||
|
(
|
||||||
|
77,
|
||||||
|
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -17160,6 +17406,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_job_worker_cleanup_migration_queues_legacy_terminal_workers() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE __yoi_schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO __yoi_schema_migrations(version, name)
|
||||||
|
VALUES (76, 'workspace schema baseline');
|
||||||
|
CREATE TABLE backend_job_attempts (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
job_id TEXT NOT NULL,
|
||||||
|
attempt_id TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
runtime_id TEXT,
|
||||||
|
worker_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO backend_job_attempts VALUES
|
||||||
|
('w', 'terminal-bound', 'a1', 'completed', 'runtime', 'worker', '1', '2'),
|
||||||
|
('w', 'terminal-unbound', 'a2', 'failed', NULL, NULL, '1', '3'),
|
||||||
|
('w', 'running', 'a3', 'dispatched', 'runtime', 'running-worker', '1', '4');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
migrate_backend_job_worker_cleanup_v76_to_v77(&conn).unwrap();
|
||||||
|
|
||||||
|
let states = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT job_id, worker_cleanup_state, worker_cleanup_completed_at FROM backend_job_attempts ORDER BY job_id",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, Option<String>>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
states,
|
||||||
|
vec![
|
||||||
|
("running".to_string(), None, None,),
|
||||||
|
(
|
||||||
|
"terminal-bound".to_string(),
|
||||||
|
Some("pending".to_string()),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"terminal-unbound".to_string(),
|
||||||
|
Some("completed".to_string()),
|
||||||
|
Some("3".to_string()),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 77);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -17179,7 +17490,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||||
73, 74, 75, 76
|
73, 74, 75, 76, 77
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -17188,7 +17499,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28);
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::feature_storage::{
|
|||||||
|
|
||||||
pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||||
|
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
pub const MAX_STAGING_ANCHORS: usize = 10;
|
||||||
/// Deterministic surface-generation policy. Input estimates use the repository's
|
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
||||||
@@ -45,6 +46,8 @@ pub enum SubjektivError {
|
|||||||
SubjectNotFound(String),
|
SubjectNotFound(String),
|
||||||
#[error("subject `{0}` is retired")]
|
#[error("subject `{0}` is retired")]
|
||||||
SubjectRetired(String),
|
SubjectRetired(String),
|
||||||
|
#[error("the selected Worker Profile does not enable subjektiv")]
|
||||||
|
ProfileDisabled,
|
||||||
#[error("staging candidate `{0}` was not found for this subject")]
|
#[error("staging candidate `{0}` was not found for this subject")]
|
||||||
CandidateNotFound(String),
|
CandidateNotFound(String),
|
||||||
#[error("staging candidate `{0}` already exists with different content")]
|
#[error("staging candidate `{0}` already exists with different content")]
|
||||||
@@ -147,6 +150,23 @@ pub struct SubjectPage {
|
|||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backend-authorized launch material for one active subject. The singleton key
|
||||||
|
/// is derived by subjektiv and then acquired through Yoi's generic singleton path.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthorizedSubjectWorkerAttachment {
|
||||||
|
pub singleton_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subject_worker_singleton_key(subject_id: &str) -> Result<String> {
|
||||||
|
validate_label("subject id", subject_id)?;
|
||||||
|
if subject_id.trim() != subject_id {
|
||||||
|
return Err(SubjektivError::InvalidRecord(
|
||||||
|
"subject id must not contain surrounding whitespace".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(format!("{SUBJECT_WORKER_SINGLETON_PREFIX}{subject_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Immutable host-recorded history that attributes one durable Session to the
|
/// Immutable host-recorded history that attributes one durable Session to the
|
||||||
/// subject and Worker that produced it. This is archival provenance only: it is
|
/// subject and Worker that produced it. This is archival provenance only: it is
|
||||||
/// deliberately not a current-Worker link or execution ownership record.
|
/// deliberately not a current-Worker link or execution ownership record.
|
||||||
@@ -1091,6 +1111,28 @@ impl SubjektivStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates an explicit product launch connection against this Workspace's
|
||||||
|
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||||
|
/// alone is insufficient: only an active local record produces host launch material.
|
||||||
|
pub fn authorize_worker_attachment(
|
||||||
|
&self,
|
||||||
|
subject_id: &str,
|
||||||
|
profile: &manifest::WorkerManifest,
|
||||||
|
) -> Result<AuthorizedSubjectWorkerAttachment> {
|
||||||
|
if !profile.feature.subjektiv.profile.enabled {
|
||||||
|
return Err(SubjektivError::ProfileDisabled);
|
||||||
|
}
|
||||||
|
let subject = self
|
||||||
|
.subject(subject_id)?
|
||||||
|
.ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?;
|
||||||
|
if subject.state != SubjectState::Active {
|
||||||
|
return Err(SubjektivError::SubjectRetired(subject_id.to_string()));
|
||||||
|
}
|
||||||
|
Ok(AuthorizedSubjectWorkerAttachment {
|
||||||
|
singleton_key: subject_worker_singleton_key(&subject.id)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Lists the first deterministic bounded page of subjects. Ordering uses
|
/// Lists the first deterministic bounded page of subjects. Ordering uses
|
||||||
/// immutable creation time with the host-issued subject id as a total-order
|
/// immutable creation time with the host-issued subject id as a total-order
|
||||||
/// tie breaker.
|
/// tie breaker.
|
||||||
|
|||||||
+51
-144
@@ -1528,6 +1528,10 @@
|
|||||||
"display_name": {
|
"display_name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"feature_connections": {
|
||||||
|
"$ref": "#/components/schemas/WorkspaceWorkerFeatureConnectionsRequest",
|
||||||
|
"description": "Explicit product Feature connections. Backend validation turns these into\ntrusted host attachments; Runtime and Profile inputs cannot self-attest them."
|
||||||
|
},
|
||||||
"initial_submit": {
|
"initial_submit": {
|
||||||
"default": [],
|
"default": [],
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1574,7 +1578,8 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"runtime_id",
|
"runtime_id",
|
||||||
"display_name"
|
"display_name",
|
||||||
|
"feature_connections"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -9428,15 +9433,15 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"SubjektivSubjectWorkerStartRequest": {
|
"SubjektivWorkerConnectionRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"worker": {
|
"subject_id": {
|
||||||
"$ref": "#/components/schemas/CreateWorkspaceWorkerRequest"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"worker"
|
"subject_id"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -12069,6 +12074,18 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"WorkerLaunchFeatureConnectionOptions": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"subjektiv": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"subjektiv"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"WorkerLaunchOptionsResponse": {
|
"WorkerLaunchOptionsResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -12128,6 +12145,9 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"feature_connections": {
|
||||||
|
"$ref": "#/components/schemas/WorkerLaunchFeatureConnectionOptions"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -12138,7 +12158,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"label",
|
"label",
|
||||||
"description"
|
"description",
|
||||||
|
"feature_connections"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -12547,6 +12568,13 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"last_finished_submission_request_id": {
|
||||||
|
"description": "Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"$ref": "#/components/schemas/WorkerState"
|
"$ref": "#/components/schemas/WorkerState"
|
||||||
}
|
}
|
||||||
@@ -14706,6 +14734,22 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"WorkspaceWorkerFeatureConnectionsRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"subjektiv": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SubjektivWorkerConnectionRequest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"WorkspaceWorkerSubject": {
|
"WorkspaceWorkerSubject": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -14750,7 +14794,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098"
|
"x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -29642,143 +29686,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "subjektiv_subject_worker_start",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "workspace_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"explode": false,
|
|
||||||
"in": "path",
|
|
||||||
"name": "subject_id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/string"
|
|
||||||
},
|
|
||||||
"style": "simple"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/SubjektivSubjectWorkerStartRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BrowserCreateWorkerResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful response"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"502": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RepositoryApiError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Error response"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearerAuth": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"browserSession": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/w/{workspace_id}/tickets": {
|
"/api/w/{workspace_id}/tickets": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ticket_list",
|
"operationId": "ticket_list",
|
||||||
|
|||||||
@@ -367,6 +367,38 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
|
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
|
||||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||||
}
|
}
|
||||||
|
if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) {
|
||||||
|
return json({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
runtimes: [{
|
||||||
|
runtime_id: "embedded-worker-runtime",
|
||||||
|
display_name: "Embedded Runtime",
|
||||||
|
built_in: true,
|
||||||
|
worker_creation_available: true,
|
||||||
|
working_directory_required: false,
|
||||||
|
status: "active",
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
default_profile: "builtin:companion",
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
id: "builtin:companion",
|
||||||
|
label: "Companion",
|
||||||
|
description: "General Workspace assistance with explicit subject Memory support.",
|
||||||
|
feature_connections: { subjektiv: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "builtin:standalone",
|
||||||
|
label: "Standalone",
|
||||||
|
description: "Independent Worker without Workspace subject Memory support.",
|
||||||
|
feature_connections: { subjektiv: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
repositories: [],
|
||||||
|
working_directories: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||||
if (url.pathname === subjectsPath) {
|
if (url.pathname === subjectsPath) {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ async function swipeMainToEnd(page: Page): Promise<void> {
|
|||||||
throw new Error("touch input did not reach the end of the resident Memory surface");
|
throw new Error("touch input did not reach the end of the resident Memory surface");
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("production subject Memory shell renders exact states, safe Markdown, provenance, and one scroll owner", async () => {
|
Deno.test("production subject Memory and Worker launch shells preserve exact scoped behavior", async () => {
|
||||||
const build = await new Deno.Command(Deno.execPath(), {
|
const build = await new Deno.Command(Deno.execPath(), {
|
||||||
args: ["task", "build"],
|
args: ["task", "build"],
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
@@ -500,6 +500,49 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown,
|
|||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
await stateContext.close();
|
await stateContext.close();
|
||||||
|
|
||||||
|
const launchContext = await browser.newContext({ viewport: { width: 768, height: 900 } });
|
||||||
|
const launchPage = await launchContext.newPage();
|
||||||
|
await launchPage.goto(`${baseUrl}/w/${workspaceId}/workers/new`);
|
||||||
|
await launchPage.getByRole("heading", { name: "New Worker", level: 1 }).waitFor();
|
||||||
|
const subjectConnection = launchPage.locator(
|
||||||
|
'[data-worker-feature-connection="subjektiv"]',
|
||||||
|
);
|
||||||
|
await subjectConnection.waitFor();
|
||||||
|
const subjectSelect = launchPage.getByRole("combobox", {
|
||||||
|
name: "Subject Memory connection",
|
||||||
|
});
|
||||||
|
assertEquals(await subjectSelect.locator('option[value="error-subject"]').count(), 0);
|
||||||
|
assertEquals(await subjectSelect.locator('option[value="paged-subject"]').count(), 0);
|
||||||
|
await launchPage.getByRole("button", { name: "Load more subjects" }).click();
|
||||||
|
await subjectSelect.locator('option[value="paged-subject"]').waitFor({ state: "attached" });
|
||||||
|
await subjectSelect.selectOption(subjectId);
|
||||||
|
assertEquals(await subjectSelect.inputValue(), subjectId);
|
||||||
|
assertEquals(
|
||||||
|
await subjectConnection.locator("code", { hasText: subjectId }).count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const profileSelect = launchPage.getByRole("combobox", { name: "Profile" });
|
||||||
|
await profileSelect.selectOption("builtin:standalone");
|
||||||
|
assertEquals(await subjectConnection.count(), 0);
|
||||||
|
await profileSelect.selectOption("builtin:companion");
|
||||||
|
await subjectConnection.waitFor();
|
||||||
|
assertEquals(await subjectSelect.inputValue(), "");
|
||||||
|
|
||||||
|
await subjectSelect.selectOption(subjectId);
|
||||||
|
const createRequestPromise = launchPage.waitForRequest((request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/api/w/${workspaceId}/workers`
|
||||||
|
);
|
||||||
|
await launchPage.getByRole("button", { name: "Start Worker" }).click();
|
||||||
|
const createRequest = await createRequestPromise;
|
||||||
|
const createPayload = createRequest.postDataJSON();
|
||||||
|
assertEquals(createPayload.profile, "builtin:companion");
|
||||||
|
assertEquals(createPayload.feature_connections, {
|
||||||
|
subjektiv: { subject_id: subjectId },
|
||||||
|
});
|
||||||
|
await launchContext.close();
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "worker-launch-dark",
|
||||||
|
"title": "Ordinary Worker launch with optional subject connection — dark",
|
||||||
|
"baseUrl": "http://127.0.0.1:15288",
|
||||||
|
"colorScheme": "dark",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Authorized Workspace member fixture",
|
||||||
|
"auth": { "kind": "anonymous" }
|
||||||
|
}],
|
||||||
|
"viewports": [
|
||||||
|
{ "label": "desktop", "width": 1440, "height": 900 },
|
||||||
|
{ "label": "narrow", "width": 768, "height": 900 },
|
||||||
|
{ "label": "mobile", "width": 390, "height": 844 },
|
||||||
|
{ "label": "compact", "width": 320, "height": 700 }
|
||||||
|
],
|
||||||
|
"routes": [{
|
||||||
|
"id": "representative",
|
||||||
|
"label": "Worker launch",
|
||||||
|
"path": "/w/memory-review/workers/new",
|
||||||
|
"goal": "Start an ordinary Worker with a compatible Profile and optionally connect an authorized existing subject.",
|
||||||
|
"dataState": "Compatible and incompatible Profiles with active, retired, long-identity, and paginated subjects.",
|
||||||
|
"ready": { "kind": "selector", "selector": "form.worker-launch-form" },
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Worker launch form" }]
|
||||||
|
}],
|
||||||
|
"processes": [{
|
||||||
|
"id": "worker-launch-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/memory_document_fixture_server.ts",
|
||||||
|
"15288",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15288/health"
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "worker-launch-light",
|
||||||
|
"title": "Ordinary Worker launch with optional subject connection — light",
|
||||||
|
"baseUrl": "http://127.0.0.1:15287",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Authorized Workspace member fixture",
|
||||||
|
"auth": { "kind": "anonymous" }
|
||||||
|
}],
|
||||||
|
"viewports": [
|
||||||
|
{ "label": "desktop", "width": 1440, "height": 900 },
|
||||||
|
{ "label": "narrow", "width": 768, "height": 900 },
|
||||||
|
{ "label": "mobile", "width": 390, "height": 844 },
|
||||||
|
{ "label": "compact", "width": 320, "height": 700 }
|
||||||
|
],
|
||||||
|
"routes": [{
|
||||||
|
"id": "representative",
|
||||||
|
"label": "Worker launch",
|
||||||
|
"path": "/w/memory-review/workers/new",
|
||||||
|
"goal": "Start an ordinary Worker with a compatible Profile and optionally connect an authorized existing subject.",
|
||||||
|
"dataState": "Compatible and incompatible Profiles with active, retired, long-identity, and paginated subjects.",
|
||||||
|
"ready": { "kind": "selector", "selector": "form.worker-launch-form" },
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Worker launch form" }]
|
||||||
|
}],
|
||||||
|
"processes": [{
|
||||||
|
"id": "worker-launch-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/memory_document_fixture_server.ts",
|
||||||
|
"15287",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15287/health"
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -34,7 +34,13 @@ export type WorkerStateSnapshot = {
|
|||||||
/**
|
/**
|
||||||
* Highest lifecycle command id observed by this controller instance.
|
* Highest lifecycle command id observed by this controller instance.
|
||||||
*/
|
*/
|
||||||
last_command_id: number, state: WorkerState, };
|
last_command_id: number,
|
||||||
|
/**
|
||||||
|
* Most recent Submit request whose controller Run has returned and whose
|
||||||
|
* post-Run state transition has been published. This is a teardown fence,
|
||||||
|
* not a successful-result claim; cancelled and failed Runs may also finish.
|
||||||
|
*/
|
||||||
|
last_finished_submission_request_id?: string | null, state: WorkerState, };
|
||||||
|
|
||||||
export type TurnResult = "finished" | "paused";
|
export type TurnResult = "finished" | "paused";
|
||||||
|
|
||||||
@@ -129,12 +135,12 @@ export type SessionMessageRole = "user" | "assistant";
|
|||||||
|
|
||||||
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
|
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
|
||||||
|
|
||||||
export type SessionToolAttachment = { media_type: string,
|
export type SessionToolAttachment = {
|
||||||
/**
|
/**
|
||||||
* Base64-encoded durable attachment body. Public snapshots preserve the
|
* Stable, bounded identity derived from the owning Session entry, attachment
|
||||||
* committed multimodal value instead of replacing it with placeholder text.
|
* position, and immutable image bytes. It is a locator, not a bearer secret.
|
||||||
*/
|
*/
|
||||||
data_base64: string, };
|
attachment_id: string, media_type: string, byte_len: number, };
|
||||||
|
|
||||||
export type RunYieldReason = "compaction";
|
export type RunYieldReason = "compaction";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
@@ -69,7 +69,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
|||||||
|
|
||||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||||
|
|
||||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||||
|
|
||||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
@@ -159,7 +159,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
|||||||
|
|
||||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||||
|
|
||||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||||
|
|
||||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
@@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c
|
|||||||
|
|
||||||
export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnostic>; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; };
|
export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnostic>; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; };
|
||||||
|
|
||||||
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
|
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
|
||||||
|
|
||||||
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
|
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; clea
|
|||||||
|
|
||||||
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
|
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
|
||||||
|
|
||||||
|
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
|
||||||
|
|
||||||
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
|
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
|
||||||
|
|
||||||
export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed";
|
export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed";
|
||||||
@@ -47,9 +49,11 @@ export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind:
|
|||||||
|
|
||||||
export type WorkerImplementationSummary = { display_hint: string; kind: string; };
|
export type WorkerImplementationSummary = { display_hint: string; kind: string; };
|
||||||
|
|
||||||
|
export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; };
|
||||||
|
|
||||||
export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array<Diagnostic>; profiles: Array<WorkerLaunchProfileCandidate>; repositories: Array<WorkingDirectoryRepositoryOption>; runtimes: Array<WorkerLaunchRuntimeOption>; working_directories: Array<WorkingDirectorySummary>; workspace_id: string; };
|
export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array<Diagnostic>; profiles: Array<WorkerLaunchProfileCandidate>; repositories: Array<WorkingDirectoryRepositoryOption>; runtimes: Array<WorkerLaunchRuntimeOption>; working_directories: Array<WorkingDirectorySummary>; workspace_id: string; };
|
||||||
|
|
||||||
export type WorkerLaunchProfileCandidate = { description: string; id: string; label: string; };
|
export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; };
|
||||||
|
|
||||||
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; };
|
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; };
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
|||||||
|
|
||||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||||
|
|
||||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||||
|
|
||||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||||
|
|
||||||
@@ -84,3 +88,5 @@ export type WorkingDirectorySource = { kind: "repository"; repository_key: strin
|
|||||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||||
|
|
||||||
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
|
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
|
||||||
|
|
||||||
|
export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; };
|
||||||
|
|||||||
@@ -166,7 +166,12 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () =>
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
}],
|
}],
|
||||||
default_profile: null,
|
default_profile: null,
|
||||||
profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }],
|
profiles: [{
|
||||||
|
id: "builtin:coder",
|
||||||
|
label: "Coder",
|
||||||
|
description: "Code",
|
||||||
|
feature_connections: { subjektiv: true },
|
||||||
|
}],
|
||||||
repositories: [{ repository_key: "main" }],
|
repositories: [{ repository_key: "main" }],
|
||||||
working_directories: [],
|
working_directories: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -235,10 +240,27 @@ Deno.test("Worker create request parser requires the complete shared request", (
|
|||||||
working_directory_id: "workdir-a",
|
working_directory_id: "workdir-a",
|
||||||
relative_cwd: null,
|
relative_cwd: null,
|
||||||
}],
|
}],
|
||||||
|
feature_connections: {},
|
||||||
control_operation_id: null,
|
control_operation_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
assertEquals(parseCreateWorkspaceWorkerRequest(request), request);
|
assertEquals(parseCreateWorkspaceWorkerRequest(request), request);
|
||||||
|
assertEquals(
|
||||||
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
...request,
|
||||||
|
feature_connections: { subjektiv: { subject_id: "subject-42" } },
|
||||||
|
}).feature_connections,
|
||||||
|
{ subjektiv: { subject_id: "subject-42" } },
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
...request,
|
||||||
|
resolved_subjektiv_attached: true,
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"unknown field resolved_subjektiv_attached",
|
||||||
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseCreateWorkspaceWorkerRequest({
|
parseCreateWorkspaceWorkerRequest({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
WorkerWorkdirAttachmentSummary,
|
WorkerWorkdirAttachmentSummary,
|
||||||
WorkerWorkspaceSummary,
|
WorkerWorkspaceSummary,
|
||||||
WorkingDirectoryRepositoryOption,
|
WorkingDirectoryRepositoryOption,
|
||||||
|
WorkspaceWorkerFeatureConnectionsRequest,
|
||||||
} from "#lib/generated/worker-launch-api.ts";
|
} from "#lib/generated/worker-launch-api.ts";
|
||||||
import type { Segment } from "#lib/generated/protocol.ts";
|
import type { Segment } from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
@@ -148,16 +149,26 @@ function runtimeOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function featureConnectionOptions(value: unknown, label: string) {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["subjektiv"], label);
|
||||||
|
return { subjektiv: boolean(item.subjektiv, `${label}.subjektiv`) };
|
||||||
|
}
|
||||||
|
|
||||||
function profileCandidate(
|
function profileCandidate(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
label: string,
|
label: string,
|
||||||
): WorkerLaunchProfileCandidate {
|
): WorkerLaunchProfileCandidate {
|
||||||
const item = record(value, label);
|
const item = record(value, label);
|
||||||
exact(item, ["id", "label", "description"], label);
|
exact(item, ["id", "label", "description", "feature_connections"], label);
|
||||||
return {
|
return {
|
||||||
id: string(item.id, `${label}.id`),
|
id: string(item.id, `${label}.id`),
|
||||||
label: string(item.label, `${label}.label`),
|
label: string(item.label, `${label}.label`),
|
||||||
description: string(item.description, `${label}.description`),
|
description: string(item.description, `${label}.description`),
|
||||||
|
feature_connections: featureConnectionOptions(
|
||||||
|
item.feature_connections,
|
||||||
|
`${label}.feature_connections`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,6 +711,20 @@ function ticketAssignment(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function featureConnectionsRequest(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): WorkspaceWorkerFeatureConnectionsRequest {
|
||||||
|
const item = record(value, label);
|
||||||
|
exact(item, ["subjektiv"], label);
|
||||||
|
const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => {
|
||||||
|
const connection = record(value, itemLabel);
|
||||||
|
exact(connection, ["subject_id"], itemLabel);
|
||||||
|
return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) };
|
||||||
|
});
|
||||||
|
return subjektiv === undefined ? {} : { subjektiv };
|
||||||
|
}
|
||||||
|
|
||||||
function unsignedInteger(value: unknown, label: string): number {
|
function unsignedInteger(value: unknown, label: string): number {
|
||||||
const parsed = number(value, label);
|
const parsed = number(value, label);
|
||||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
@@ -853,6 +878,7 @@ export function parseCreateWorkspaceWorkerRequest(
|
|||||||
"ticket_assignment",
|
"ticket_assignment",
|
||||||
"initial_submit",
|
"initial_submit",
|
||||||
"workdir_attachments",
|
"workdir_attachments",
|
||||||
|
"feature_connections",
|
||||||
"control_operation_id",
|
"control_operation_id",
|
||||||
],
|
],
|
||||||
"Worker create request",
|
"Worker create request",
|
||||||
@@ -870,6 +896,10 @@ export function parseCreateWorkspaceWorkerRequest(
|
|||||||
"workdir_attachments",
|
"workdir_attachments",
|
||||||
workingDirectorySelection,
|
workingDirectorySelection,
|
||||||
),
|
),
|
||||||
|
feature_connections: featureConnectionsRequest(
|
||||||
|
item.feature_connections,
|
||||||
|
"feature_connections",
|
||||||
|
),
|
||||||
control_operation_id: nullableString(
|
control_operation_id: nullableString(
|
||||||
item.control_operation_id,
|
item.control_operation_id,
|
||||||
"control_operation_id",
|
"control_operation_id",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SessionToolAttachment } from "#lib/generated/protocol.ts";
|
||||||
|
import {
|
||||||
|
ConsoleAttachmentFetchError,
|
||||||
|
loadConsoleAttachment,
|
||||||
|
} from "./attachment-loader.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attachment: SessionToolAttachment;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { attachment, url }: Props = $props();
|
||||||
|
let root: HTMLElement | null = $state(null);
|
||||||
|
let objectUrl = $state<string | null>(null);
|
||||||
|
let disposed = false;
|
||||||
|
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!url || status === "loading" || status === "ready") return;
|
||||||
|
status = "loading";
|
||||||
|
try {
|
||||||
|
const blob = await loadConsoleAttachment(url);
|
||||||
|
if (disposed) return;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
status = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (disposed) return;
|
||||||
|
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(): string {
|
||||||
|
switch (status) {
|
||||||
|
case "idle": return url ? "Image waiting to load" : "Image Session is unavailable";
|
||||||
|
case "loading": return "Loading image…";
|
||||||
|
case "missing": return "Image is missing";
|
||||||
|
case "expired": return "Image retention has expired";
|
||||||
|
case "unauthorized": return "Image access was denied";
|
||||||
|
case "network": return "Image network request failed";
|
||||||
|
case "failed": return "Image could not be loaded";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!root || !url || status !== "idle") return;
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
void load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
observer.disconnect();
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}, { rootMargin: "160px" });
|
||||||
|
observer.observe(root);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
disposed = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
||||||
|
{#if status === "ready" && objectUrl}
|
||||||
|
<img src={objectUrl} alt="Tool output attachment" />
|
||||||
|
{:else}
|
||||||
|
<span>{statusText()}</span>
|
||||||
|
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
|
||||||
|
<button type="button" onclick={() => void load()}>Retry</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.console-attachment {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 52rem);
|
||||||
|
max-height: 42rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
||||||
|
import ConsoleAttachmentImage from '#lib/workspace/console/ConsoleAttachmentImage.svelte';
|
||||||
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
||||||
|
import type { SessionToolAttachment } from '#lib/generated/protocol.ts';
|
||||||
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: ConsoleLine;
|
item: ConsoleLine;
|
||||||
|
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { item }: Props = $props();
|
let { item, attachmentUrl = () => null }: Props = $props();
|
||||||
let detailOpen = $state(false);
|
let detailOpen = $state(false);
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
@@ -140,6 +143,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="console-plain-text">{item.body || '—'}</p>
|
<p class="console-plain-text">{item.body || '—'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.attachments?.length}
|
||||||
|
<div class="console-attachments">
|
||||||
|
{#each item.attachments as attachment (attachment.attachment_id)}
|
||||||
|
<ConsoleAttachmentImage {attachment} url={attachmentUrl(attachment)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if item.diff}
|
{#if item.diff}
|
||||||
<div class="console-diff" role="group" aria-label="Edit diff">
|
<div class="console-diff" role="group" aria-label="Edit diff">
|
||||||
{#each item.diff as diffLine}
|
{#each item.diff as diffLine}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
clearConsoleAttachmentCache,
|
||||||
|
ConsoleAttachmentFetchError,
|
||||||
|
loadConsoleAttachment,
|
||||||
|
} from "./attachment-loader.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStrictEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error("expected values to be strictly identical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("console attachment loads are lazy-callable and deduplicate in-flight and successful fetches", async () => {
|
||||||
|
clearConsoleAttachmentCache();
|
||||||
|
let calls = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => release = resolve);
|
||||||
|
const fetcher = (async () => {
|
||||||
|
calls += 1;
|
||||||
|
await gate;
|
||||||
|
return new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
assertEquals(calls, 0);
|
||||||
|
const first = loadConsoleAttachment("/attachment/a", fetcher);
|
||||||
|
const second = loadConsoleAttachment("/attachment/a", fetcher);
|
||||||
|
assertStrictEquals(first, second);
|
||||||
|
assertEquals(calls, 1);
|
||||||
|
release();
|
||||||
|
const blob = await first;
|
||||||
|
assertEquals(blob.size, 3);
|
||||||
|
assertStrictEquals(
|
||||||
|
await loadConsoleAttachment("/attachment/a", fetcher),
|
||||||
|
blob,
|
||||||
|
);
|
||||||
|
assertEquals(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("console attachment failures stay local and classify retention and authorization", async () => {
|
||||||
|
clearConsoleAttachmentCache();
|
||||||
|
for (
|
||||||
|
const [status, kind] of [[404, "missing"], [410, "expired"], [
|
||||||
|
403,
|
||||||
|
"unauthorized",
|
||||||
|
]] as const
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await loadConsoleAttachment(
|
||||||
|
`/attachment/${status}`,
|
||||||
|
(async () => new Response(null, { status })) as typeof fetch,
|
||||||
|
);
|
||||||
|
throw new Error("expected failure");
|
||||||
|
} catch (error) {
|
||||||
|
assertEquals(
|
||||||
|
error instanceof ConsoleAttachmentFetchError && error.kind,
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type ConsoleAttachmentFetchErrorKind =
|
||||||
|
| "missing"
|
||||||
|
| "expired"
|
||||||
|
| "unauthorized"
|
||||||
|
| "network"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export class ConsoleAttachmentFetchError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly kind: ConsoleAttachmentFetchErrorKind,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConsoleAttachmentFetchError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successful = new Map<string, Blob>();
|
||||||
|
const inFlight = new Map<string, Promise<Blob>>();
|
||||||
|
|
||||||
|
export function loadConsoleAttachment(
|
||||||
|
url: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const cached = successful.get(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
const pending = inFlight.get(url);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(url, { credentials: "same-origin" });
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConsoleAttachmentFetchError(
|
||||||
|
"network",
|
||||||
|
error instanceof Error ? error.message : "Network request failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const kind: ConsoleAttachmentFetchErrorKind = response.status === 404
|
||||||
|
? "missing"
|
||||||
|
: response.status === 410
|
||||||
|
? "expired"
|
||||||
|
: response.status === 401 || response.status === 403
|
||||||
|
? "unauthorized"
|
||||||
|
: "failed";
|
||||||
|
throw new ConsoleAttachmentFetchError(kind, `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
successful.set(url, blob);
|
||||||
|
return blob;
|
||||||
|
})().finally(() => inFlight.delete(url));
|
||||||
|
inFlight.set(url, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearConsoleAttachmentCache(): void {
|
||||||
|
successful.clear();
|
||||||
|
inFlight.clear();
|
||||||
|
}
|
||||||
@@ -3741,6 +3741,11 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
summary: "read",
|
summary: "read",
|
||||||
content: "contents",
|
content: "contents",
|
||||||
is_error: false,
|
is_error: false,
|
||||||
|
attachments: [{
|
||||||
|
attachment_id: "attachment-1",
|
||||||
|
media_type: "image/png",
|
||||||
|
byte_len: 5_735_340,
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3753,6 +3758,15 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
);
|
);
|
||||||
assertEquals(lines[0].entryId, "tool-result-entry");
|
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||||
assertEquals(lines[0].toolCall?.state, "done");
|
assertEquals(lines[0].toolCall?.state, "done");
|
||||||
|
assertEquals(lines[0].attachments, [{
|
||||||
|
attachment_id: "attachment-1",
|
||||||
|
media_type: "image/png",
|
||||||
|
byte_len: 5_735_340,
|
||||||
|
}]);
|
||||||
|
assertEquals(
|
||||||
|
projectConsoleLines(lines, "overview")[0].attachments,
|
||||||
|
lines[0].attachments,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ReasoningConfig,
|
ReasoningConfig,
|
||||||
Segment,
|
Segment,
|
||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
|
SessionToolAttachment,
|
||||||
WorkerState,
|
WorkerState,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
@@ -63,6 +64,7 @@ type ToolCallView = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
command?: CommandSnapshot;
|
command?: CommandSnapshot;
|
||||||
|
attachments?: SessionToolAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConsoleDiffLine = {
|
export type ConsoleDiffLine = {
|
||||||
@@ -99,6 +101,7 @@ export type ConsoleLine = {
|
|||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
toolCall?: ToolCallView;
|
toolCall?: ToolCallView;
|
||||||
|
attachments?: SessionToolAttachment[];
|
||||||
/** Number of calls represented by a lower-level aggregate line. */
|
/** Number of calls represented by a lower-level aggregate line. */
|
||||||
toolCallCount?: number;
|
toolCallCount?: number;
|
||||||
/** Original Read rows, retained so history can reconcile before aggregation. */
|
/** Original Read rows, retained so history can reconcile before aggregation. */
|
||||||
@@ -593,7 +596,12 @@ export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
|
|||||||
if (line.systemItemKind === "task_reminder") continue;
|
if (line.systemItemKind === "task_reminder") continue;
|
||||||
if (isOverviewThinkingLine(line)) continue;
|
if (isOverviewThinkingLine(line)) continue;
|
||||||
if (line.kind === "tool" && line.toolCall) {
|
if (line.kind === "tool" && line.toolCall) {
|
||||||
toolGroup.push(line);
|
if (line.attachments?.length) {
|
||||||
|
flushTools();
|
||||||
|
overview.push(line);
|
||||||
|
} else {
|
||||||
|
toolGroup.push(line);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
flushTools();
|
flushTools();
|
||||||
@@ -1634,7 +1642,7 @@ function attachToolResult(
|
|||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
id: string,
|
id: string,
|
||||||
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
result: Pick<ToolCallView, "summary" | "output" | "isError" | "attachments">,
|
||||||
): void {
|
): void {
|
||||||
const index = findToolCallLineIndex(projection, id);
|
const index = findToolCallLineIndex(projection, id);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
@@ -1711,6 +1719,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
toolStatus: toolCallStatus(toolCall),
|
toolStatus: toolCallStatus(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
|
attachments: toolCall.attachments,
|
||||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||||
error: toolCall.state === "error" || commandError,
|
error: toolCall.state === "error" || commandError,
|
||||||
};
|
};
|
||||||
@@ -1816,6 +1825,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
const hasError = calls.some((call) => call.state === "error");
|
const hasError = calls.some((call) => call.state === "error");
|
||||||
const paths = calls.map(readPath);
|
const paths = calls.map(readPath);
|
||||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||||
|
const attachments = group.flatMap((line) => line.attachments ?? []);
|
||||||
const body = compactLines([
|
const body = compactLines([
|
||||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||||
inProgress && paths.length > visiblePaths.length
|
inProgress && paths.length > visiblePaths.length
|
||||||
@@ -1835,8 +1845,10 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
source: "event",
|
source: "event",
|
||||||
streaming: inProgress,
|
streaming: inProgress,
|
||||||
error: hasError,
|
error: hasError,
|
||||||
|
attachments,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
...calls[0]!,
|
...calls[0]!,
|
||||||
|
attachments,
|
||||||
state: hasError ? "error" : inProgress ? "running" : "done",
|
state: hasError ? "error" : inProgress ? "running" : "done",
|
||||||
isError: hasError,
|
isError: hasError,
|
||||||
},
|
},
|
||||||
@@ -2361,6 +2373,7 @@ function applySessionEntry(
|
|||||||
summary: value["summary"],
|
summary: value["summary"],
|
||||||
content: value["content"],
|
content: value["content"],
|
||||||
is_error: value["is_error"],
|
is_error: value["is_error"],
|
||||||
|
attachments: value["attachments"],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "system_item": {
|
case "system_item": {
|
||||||
@@ -2509,6 +2522,10 @@ function applyLoggedItem(
|
|||||||
summary: stringField(item, "summary") ?? "",
|
summary: stringField(item, "summary") ?? "",
|
||||||
output: stringField(item, "content"),
|
output: stringField(item, "content"),
|
||||||
isError: item["is_error"] === true,
|
isError: item["is_error"] === true,
|
||||||
|
attachments: arrayField(
|
||||||
|
item,
|
||||||
|
"attachments",
|
||||||
|
) as SessionToolAttachment[],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile",
|
|||||||
...options,
|
...options,
|
||||||
profiles: [
|
profiles: [
|
||||||
...options.profiles,
|
...options.profiles,
|
||||||
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
|
{
|
||||||
|
id: "builtin:reviewer",
|
||||||
|
label: "Reviewer",
|
||||||
|
description: "review",
|
||||||
|
feature_connections: { subjektiv: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const form = defaultWorkerLaunchForm(
|
const form = defaultWorkerLaunchForm(
|
||||||
@@ -195,10 +200,27 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits multiple aliased attachments"
|
|||||||
relative_cwd: null,
|
relative_cwd: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
feature_connections: {},
|
||||||
control_operation_id: null,
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("buildCreateWorkspaceWorkerRequest keeps subject attachment explicit and separate from Profile", () => {
|
||||||
|
const unattached = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
profile: "builtin:companion",
|
||||||
|
}));
|
||||||
|
assertEquals(unattached.feature_connections, {});
|
||||||
|
|
||||||
|
const attached = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||||
|
profile: "builtin:companion",
|
||||||
|
subjektiv_subject_id: " subject-42 ",
|
||||||
|
}));
|
||||||
|
assertEquals(attached.feature_connections, {
|
||||||
|
subjektiv: { subject_id: "subject-42" },
|
||||||
|
});
|
||||||
|
assertEquals(attached.profile, "builtin:companion");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => {
|
Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]),
|
workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]),
|
||||||
@@ -268,6 +290,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for
|
|||||||
ticket_assignment: null,
|
ticket_assignment: null,
|
||||||
initial_submit: [{ kind: "text", content: "chat" }],
|
initial_submit: [{ kind: "text", content: "chat" }],
|
||||||
workdir_attachments: [],
|
workdir_attachments: [],
|
||||||
|
feature_connections: {},
|
||||||
control_operation_id: null,
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -280,6 +303,7 @@ function emptyForm(
|
|||||||
runtime_id: "",
|
runtime_id: "",
|
||||||
display_name: "",
|
display_name: "",
|
||||||
profile: "",
|
profile: "",
|
||||||
|
subjektiv_subject_id: "",
|
||||||
initial_text: "",
|
initial_text: "",
|
||||||
workdir_attachments: [],
|
workdir_attachments: [],
|
||||||
working_directory_repository_key: "",
|
working_directory_repository_key: "",
|
||||||
@@ -340,8 +364,18 @@ const options: WorkerLaunchOptionsResponse = {
|
|||||||
],
|
],
|
||||||
default_profile: "builtin:coder",
|
default_profile: "builtin:coder",
|
||||||
profiles: [
|
profiles: [
|
||||||
{ id: "builtin:companion", label: "Companion", description: "chat" },
|
{
|
||||||
{ id: "builtin:coder", label: "Coder", description: "code" },
|
id: "builtin:companion",
|
||||||
|
label: "Companion",
|
||||||
|
description: "chat",
|
||||||
|
feature_connections: { subjektiv: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "builtin:coder",
|
||||||
|
label: "Coder",
|
||||||
|
description: "code",
|
||||||
|
feature_connections: { subjektiv: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
repositories: [
|
repositories: [
|
||||||
{ repository_key: "repo", default_selector: "HEAD" },
|
{ repository_key: "repo", default_selector: "HEAD" },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type WorkerLaunchFormState = {
|
|||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
subjektiv_subject_id: string;
|
||||||
initial_text: string;
|
initial_text: string;
|
||||||
workdir_attachments: WorkerLaunchAttachmentFormState[];
|
workdir_attachments: WorkerLaunchAttachmentFormState[];
|
||||||
working_directory_repository_key: string;
|
working_directory_repository_key: string;
|
||||||
@@ -103,6 +104,7 @@ export function defaultWorkerLaunchForm(
|
|||||||
options?.profiles.some((candidate) => candidate.id === current.profile)
|
options?.profiles.some((candidate) => candidate.id === current.profile)
|
||||||
? current.profile
|
? current.profile
|
||||||
: preferredProfile?.id || "",
|
: preferredProfile?.id || "",
|
||||||
|
subjektiv_subject_id: current.subjektiv_subject_id,
|
||||||
initial_text: current.initial_text,
|
initial_text: current.initial_text,
|
||||||
workdir_attachments: workdirAttachments,
|
workdir_attachments: workdirAttachments,
|
||||||
working_directory_repository_key:
|
working_directory_repository_key:
|
||||||
@@ -179,6 +181,9 @@ export function buildCreateWorkspaceWorkerRequest(
|
|||||||
? [{ kind: "text", content: form.initial_text }]
|
? [{ kind: "text", content: form.initial_text }]
|
||||||
: [],
|
: [],
|
||||||
workdir_attachments: validatedAttachments(form.workdir_attachments),
|
workdir_attachments: validatedAttachments(form.workdir_attachments),
|
||||||
|
feature_connections: form.subjektiv_subject_id.trim()
|
||||||
|
? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } }
|
||||||
|
: {},
|
||||||
control_operation_id: null,
|
control_operation_id: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,68 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
.worker-feature-connection {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading h3,
|
||||||
|
.worker-feature-connection-heading p,
|
||||||
|
.worker-feature-connection-selection,
|
||||||
|
.worker-feature-connection-state,
|
||||||
|
.worker-feature-connection-note {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading h3 {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading p,
|
||||||
|
.worker-feature-connection-note,
|
||||||
|
.worker-feature-connection-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.worker-feature-connection-selection {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-selection code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.worker-feature-connection-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.worker-feature-connection-state.is-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.worker-subject-load-more {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
.worker-new-actions {
|
.worker-new-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -213,6 +275,14 @@
|
|||||||
.worker-attachment-row {
|
.worker-attachment-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.worker-feature-connection-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.worker-feature-connection-heading > span {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
.worker-attachment-remove {
|
.worker-attachment-remove {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
RewindTarget,
|
RewindTarget,
|
||||||
Segment,
|
Segment,
|
||||||
SessionHistoryPage,
|
SessionHistoryPage,
|
||||||
|
SessionToolAttachment,
|
||||||
} from "#lib/generated/protocol.ts";
|
} from "#lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
MAX_FILES_PER_SUBMISSION,
|
MAX_FILES_PER_SUBMISSION,
|
||||||
@@ -219,6 +220,17 @@
|
|||||||
const selectedHistory = $derived(
|
const selectedHistory = $derived(
|
||||||
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||||
);
|
);
|
||||||
|
const selectedAttachmentSessionId = $derived(
|
||||||
|
selectedWorkerView.sessionId ?? selectedHistory.sessionId,
|
||||||
|
);
|
||||||
|
function attachmentUrl(attachment: SessionToolAttachment): string | null {
|
||||||
|
if (!runtimeId || !workerId || !selectedAttachmentSessionId) return null;
|
||||||
|
return workerApiPath(
|
||||||
|
`runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}` +
|
||||||
|
`/sessions/${encodeURIComponent(selectedAttachmentSessionId)}` +
|
||||||
|
`/attachments/${encodeURIComponent(attachment.attachment_id)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const committedHistoryLines = $derived(
|
const committedHistoryLines = $derived(
|
||||||
projectSessionHistoryEntries(
|
projectSessionHistoryEntries(
|
||||||
historyEntries(selectedHistory),
|
historyEntries(selectedHistory),
|
||||||
@@ -1755,7 +1767,7 @@
|
|||||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||||
<ol class="console-log">
|
<ol class="console-log">
|
||||||
{#each lines as item (item.id)}
|
{#each lines as item (item.id)}
|
||||||
<ConsoleLineItem {item} />
|
<ConsoleLineItem {item} {attachmentUrl} />
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
parseWorkerApiError,
|
parseWorkerApiError,
|
||||||
parseWorkerLaunchOptionsResponse,
|
parseWorkerLaunchOptionsResponse,
|
||||||
} from '#lib/workspace/api/workers.ts';
|
} from '#lib/workspace/api/workers.ts';
|
||||||
|
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
|
||||||
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||||
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
WorkerLaunchOptionsResponse,
|
WorkerLaunchOptionsResponse,
|
||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
} from '#lib/workspace/sidebar/types.ts';
|
} from '#lib/workspace/sidebar/types.ts';
|
||||||
|
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
type DisplayError = {
|
type DisplayError = {
|
||||||
@@ -69,6 +71,14 @@
|
|||||||
: 'builtin:coder'
|
: 'builtin:coder'
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
|
let subjektivSubjectId = $state('');
|
||||||
|
let subjects = $state<SubjektivSubjectResponse[]>([]);
|
||||||
|
let subjectsLoading = $state(false);
|
||||||
|
let subjectsLoaded = $state(false);
|
||||||
|
let subjectsError = $state<string | null>(null);
|
||||||
|
let subjectsNextCursor = $state<string | null>(null);
|
||||||
|
let subjectsHaveMore = $state(false);
|
||||||
|
let previousProfile = $state('');
|
||||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||||
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||||
let workingDirectoryDisplayName = $state('');
|
let workingDirectoryDisplayName = $state('');
|
||||||
@@ -80,6 +90,10 @@
|
|||||||
);
|
);
|
||||||
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||||
|
let selectedProfile = $derived(options?.profiles.find((candidate) => candidate.id === profile));
|
||||||
|
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
|
||||||
|
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
|
||||||
|
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
|
||||||
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
||||||
let availableWorkingDirectories = $derived(
|
let availableWorkingDirectories = $derived(
|
||||||
selectedRuntimeAllowsNoWorkdir
|
selectedRuntimeAllowsNoWorkdir
|
||||||
@@ -130,6 +144,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (profile !== previousProfile) {
|
||||||
|
subjektivSubjectId = '';
|
||||||
|
previousProfile = profile;
|
||||||
|
}
|
||||||
|
if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) {
|
||||||
|
void loadSubjects(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function nextAttachmentAlias(): string {
|
function nextAttachmentAlias(): string {
|
||||||
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
||||||
if (!aliases.has('workdir')) return 'workdir';
|
if (!aliases.has('workdir')) return 'workdir';
|
||||||
@@ -178,6 +202,7 @@
|
|||||||
runtime_id: runtimeId,
|
runtime_id: runtimeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
profile,
|
profile,
|
||||||
|
subjektiv_subject_id: subjektivSubjectId,
|
||||||
initial_text: initialText,
|
initial_text: initialText,
|
||||||
workdir_attachments: workdirAttachments,
|
workdir_attachments: workdirAttachments,
|
||||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||||
@@ -186,6 +211,7 @@
|
|||||||
runtimeId = form.runtime_id;
|
runtimeId = form.runtime_id;
|
||||||
displayName = form.display_name;
|
displayName = form.display_name;
|
||||||
profile = form.profile;
|
profile = form.profile;
|
||||||
|
subjektivSubjectId = form.subjektiv_subject_id;
|
||||||
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
|
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
|
||||||
runtime.runtime_id === form.runtime_id
|
runtime.runtime_id === form.runtime_id
|
||||||
)?.working_directory_required !== false;
|
)?.working_directory_required !== false;
|
||||||
@@ -215,6 +241,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSubjects(append: boolean) {
|
||||||
|
if (!workspaceId || subjectsLoading) return;
|
||||||
|
const cursor = append ? subjectsNextCursor : null;
|
||||||
|
if (append && !cursor) return;
|
||||||
|
subjectsLoading = true;
|
||||||
|
subjectsLoaded = true;
|
||||||
|
subjectsError = null;
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ limit: '100' });
|
||||||
|
if (cursor) query.set('cursor', cursor);
|
||||||
|
const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`subject list failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = parseSubjektivSubjectListResponse(
|
||||||
|
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
if (append) {
|
||||||
|
const byId = new Map(subjects.map((subject) => [subject.id, subject]));
|
||||||
|
for (const subject of payload.items) byId.set(subject.id, subject);
|
||||||
|
subjects = [...byId.values()];
|
||||||
|
} else {
|
||||||
|
subjects = payload.items;
|
||||||
|
}
|
||||||
|
subjectsNextCursor = payload.next_cursor ?? null;
|
||||||
|
subjectsHaveMore = payload.has_more;
|
||||||
|
if (
|
||||||
|
subjektivSubjectId &&
|
||||||
|
!subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active')
|
||||||
|
) {
|
||||||
|
subjektivSubjectId = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
subjectsError = err instanceof Error ? err.message : 'subject list failed';
|
||||||
|
} finally {
|
||||||
|
subjectsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createWorkingDirectory() {
|
async function createWorkingDirectory() {
|
||||||
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
||||||
if (attachmentIndex < 0) {
|
if (attachmentIndex < 0) {
|
||||||
@@ -304,6 +369,7 @@
|
|||||||
runtime_id: runtimeId,
|
runtime_id: runtimeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
profile,
|
profile,
|
||||||
|
subjektiv_subject_id: subjektivSubjectId,
|
||||||
initial_text: initialText,
|
initial_text: initialText,
|
||||||
workdir_attachments: workdirAttachments,
|
workdir_attachments: workdirAttachments,
|
||||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||||
@@ -511,6 +577,55 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if subjektivConnectionAvailable}
|
||||||
|
<div class="worker-feature-connection" data-worker-feature-connection="subjektiv">
|
||||||
|
<div class="worker-feature-connection-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Subject Memory</h3>
|
||||||
|
<p>Optionally connect this Worker to one existing active subject.</p>
|
||||||
|
</div>
|
||||||
|
<span>Optional</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Subject</span>
|
||||||
|
<select bind:value={subjektivSubjectId} aria-label="Subject Memory connection">
|
||||||
|
<option value="">Do not connect a subject</option>
|
||||||
|
{#each activeSubjects as subject (subject.id)}
|
||||||
|
<option value={subject.id}>{subject.role} · {subject.id}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if selectedSubject}
|
||||||
|
<p class="worker-feature-connection-selection">
|
||||||
|
<strong>{selectedSubject.role}</strong>
|
||||||
|
<code title={selectedSubject.id}>{selectedSubject.id}</code>
|
||||||
|
{#if selectedSubject.current_worker}
|
||||||
|
<span>Current Worker: {selectedSubject.current_worker.display_name}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if subjectsLoading && subjects.length === 0}
|
||||||
|
<p class="worker-feature-connection-state" role="status">Loading authorized subjects…</p>
|
||||||
|
{:else if subjectsError}
|
||||||
|
<div class="worker-feature-connection-state is-error" role="alert">
|
||||||
|
<span>{subjectsError}</span>
|
||||||
|
<button type="button" class="secondary-button" onclick={() => void loadSubjects(false)}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if subjectsLoaded && activeSubjects.length === 0}
|
||||||
|
<p class="worker-feature-connection-state" role="status">No active subjects are available.</p>
|
||||||
|
{/if}
|
||||||
|
{#if subjectsHaveMore}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-button worker-subject-load-more"
|
||||||
|
disabled={subjectsLoading}
|
||||||
|
onclick={() => void loadSubjects(true)}
|
||||||
|
>{subjectsLoading ? 'Loading…' : 'Load more subjects'}</button>
|
||||||
|
{/if}
|
||||||
|
<p class="worker-feature-connection-note">Profile enablement does not connect a subject by itself.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
<span>Initial text</span>
|
<span>Initial text</span>
|
||||||
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||||
|
|||||||
Reference in New Issue
Block a user