flow: own worker flow state in runtime sessions
This commit is contained in:
@@ -19,6 +19,7 @@ async-trait.workspace = true
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
futures.workspace = true
|
||||
flow = { path = "../flow" }
|
||||
manifest.workspace = true
|
||||
protocol = { workspace = true }
|
||||
project-record.workspace = true
|
||||
|
||||
@@ -221,6 +221,8 @@ pub struct HostSummary {
|
||||
pub struct WorkerWorkspaceSummary {
|
||||
pub visibility: String,
|
||||
pub identity: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1495,6 +1497,7 @@ impl EmbeddedWorkerRuntime {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "backend_internal".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
workspace_id: summary.workspace_id.clone(),
|
||||
},
|
||||
state: embedded_worker_status_label(summary.status).to_string(),
|
||||
last_seen_at: None,
|
||||
@@ -1533,6 +1536,7 @@ impl EmbeddedWorkerRuntime {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "backend_internal".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
workspace_id: detail.workspace_id.clone(),
|
||||
},
|
||||
state: embedded_worker_status_label(detail.status).to_string(),
|
||||
last_seen_at: None,
|
||||
@@ -2575,6 +2579,7 @@ impl RemoteWorkerRuntime {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "remote_runtime".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
workspace_id: summary.workspace_id.clone(),
|
||||
},
|
||||
state: embedded_worker_status_label(summary.status).to_string(),
|
||||
last_seen_at: None,
|
||||
@@ -2617,6 +2622,7 @@ impl RemoteWorkerRuntime {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "remote_runtime".to_string(),
|
||||
identity: "runtime_registry_worker".to_string(),
|
||||
workspace_id: detail.workspace_id.clone(),
|
||||
},
|
||||
state: embedded_worker_status_label(detail.status).to_string(),
|
||||
last_seen_at: None,
|
||||
@@ -3885,6 +3891,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "none".to_string(),
|
||||
identity: "unsupported".to_string(),
|
||||
workspace_id: None,
|
||||
},
|
||||
state: "unsupported".to_string(),
|
||||
last_seen_at: None,
|
||||
@@ -4329,6 +4336,7 @@ mod tests {
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "opaque".to_string(),
|
||||
identity: host_id.to_string(),
|
||||
workspace_id: None,
|
||||
},
|
||||
state: "available".to_string(),
|
||||
last_seen_at: None,
|
||||
|
||||
@@ -57,6 +57,8 @@ pub enum Error {
|
||||
Ticket(#[from] ticket::TicketError),
|
||||
#[error("yaml error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("invalid project record id `{0}`")]
|
||||
InvalidRecordId(String),
|
||||
#[error("workspace backend config error: {0}")]
|
||||
|
||||
@@ -12,6 +12,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{Duration, SecondsFormat, Utc};
|
||||
use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use memory::backend::{
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
|
||||
@@ -96,9 +97,9 @@ use crate::runtime_subscription::RuntimeSubscriptionBroker;
|
||||
use crate::skills;
|
||||
use crate::store::{
|
||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||
DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, TicketWorkerAssignmentRecord,
|
||||
UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||
WorkspaceRecord,
|
||||
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
|
||||
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord,
|
||||
WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{
|
||||
@@ -700,6 +701,18 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
.put(scoped_update_profile_source)
|
||||
.delete(scoped_delete_profile_source),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/flows",
|
||||
get(scoped_list_flows).put(scoped_put_flow),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/flows/resolve",
|
||||
post(scoped_resolve_flow_source),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/flows/{flow_id}",
|
||||
get(scoped_get_flow),
|
||||
)
|
||||
.route("/api/tickets", get(list_tickets))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets",
|
||||
@@ -1669,6 +1682,19 @@ struct ScopedWorkspacePath {
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedFlowPath {
|
||||
workspace_id: String,
|
||||
flow_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PutFlowRequest {
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AttachCurrentWorkerWorkdirRequest {
|
||||
@@ -1753,6 +1779,107 @@ fn validate_workspace_scope(api: &WorkspaceApi, workspace_id: &str) -> ApiResult
|
||||
}
|
||||
}
|
||||
|
||||
async fn scoped_list_flows(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<Vec<FlowSourceRecord>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(api.store.list_flow_sources(&path.workspace_id)?))
|
||||
}
|
||||
|
||||
async fn scoped_put_flow(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<PutFlowRequest>,
|
||||
) -> ApiResult<Json<FlowSourceRecord>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let definition = flow::compile_flow_source(&request.content).map_err(|error| {
|
||||
Error::InvalidInput(format!(
|
||||
"invalid Flow source: {}",
|
||||
serde_json::to_string(&error.diagnostics)
|
||||
.unwrap_or_else(|_| "diagnostics unavailable".to_string())
|
||||
))
|
||||
})?;
|
||||
let expected_path = format!("flows/{}.dcdl", definition.name);
|
||||
if request.path != expected_path {
|
||||
return Err(
|
||||
Error::InvalidInput(format!("Flow source path must be `{expected_path}`")).into(),
|
||||
);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
Ok(Json(api.store.put_flow_source_for_kind(
|
||||
&path.workspace_id,
|
||||
FlowSourceKind::Workspace,
|
||||
&request.path,
|
||||
&request.content,
|
||||
&now,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_resolve_flow_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<FlowSourceResolveRequest>,
|
||||
) -> ApiResult<Json<ResolvedFlowSource>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let resolved = match &request.selector {
|
||||
flow::FlowSelector::Builtin { slug } => {
|
||||
let builtin = flow::builtin_flow_source(slug)
|
||||
.ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?;
|
||||
let definition = builtin.compile().map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"compile built-in Flow {slug:?}: {:?}",
|
||||
error.diagnostics
|
||||
))
|
||||
})?;
|
||||
ResolvedFlowSource {
|
||||
selector: request.selector.clone(),
|
||||
workspace_id: path.workspace_id,
|
||||
flow_id: format!("builtin:{slug}"),
|
||||
revision: builtin.revision,
|
||||
content_digest: definition.content_digest.clone(),
|
||||
definition,
|
||||
}
|
||||
}
|
||||
flow::FlowSelector::Workspace { slug } => {
|
||||
let source = api
|
||||
.store
|
||||
.get_flow_source_by_name(&path.workspace_id, FlowSourceKind::Workspace, slug)?
|
||||
.ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?;
|
||||
let revision = api
|
||||
.store
|
||||
.get_flow_source_revision(&path.workspace_id, &source.flow_id, source.revision)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(format!(
|
||||
"resolved Flow revision {}@{} is missing",
|
||||
source.flow_id, source.revision
|
||||
))
|
||||
})?;
|
||||
ResolvedFlowSource {
|
||||
selector: request.selector.clone(),
|
||||
workspace_id: path.workspace_id,
|
||||
flow_id: source.flow_id,
|
||||
revision: source.revision,
|
||||
content_digest: revision.content_digest,
|
||||
definition: revision.definition,
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Json(resolved))
|
||||
}
|
||||
|
||||
async fn scoped_get_flow(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedFlowPath>,
|
||||
) -> ApiResult<Json<FlowSourceRecord>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let source = api
|
||||
.store
|
||||
.get_flow_source(&path.workspace_id, &path.flow_id)?
|
||||
.ok_or_else(|| Error::InvalidRecordId(path.flow_id))?;
|
||||
Ok(Json(source))
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -3157,7 +3284,7 @@ fn notify_ticket_recipients(
|
||||
|
||||
fn authenticate_worker_mutation_source(
|
||||
api: &WorkspaceApi,
|
||||
_workspace_id: &str,
|
||||
workspace_id: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<WorkerMutationSource> {
|
||||
let runtime_id = headers
|
||||
@@ -3173,9 +3300,14 @@ fn authenticate_worker_mutation_source(
|
||||
Error::WorkerSourceIdentity("missing Runtime-bound Worker id".to_string())
|
||||
})?;
|
||||
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||
api.runtime.worker(&worker).map_err(|_| {
|
||||
let summary = api.runtime.worker(&worker).map_err(|_| {
|
||||
Error::WorkerSourceIdentity("Runtime-bound Worker identity does not exist".to_string())
|
||||
})?;
|
||||
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
|
||||
return Err(Error::WorkerSourceIdentity(format!(
|
||||
"Runtime-bound Worker is not scoped to Workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
Ok(worker)
|
||||
}
|
||||
|
||||
@@ -6757,7 +6889,7 @@ async fn create_workspace_worker(
|
||||
} else {
|
||||
Some(EmbeddedWorkerInput {
|
||||
kind: EmbeddedWorkerInputKind::User,
|
||||
content: initial_text,
|
||||
content: initial_text.clone(),
|
||||
segments: None,
|
||||
})
|
||||
};
|
||||
@@ -6776,8 +6908,9 @@ async fn create_workspace_worker(
|
||||
if resolved_working_directory.is_none() {
|
||||
reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?;
|
||||
}
|
||||
let runtime_id = request.runtime_id.clone();
|
||||
let result = api.spawn_workspace_worker(
|
||||
&request.runtime_id,
|
||||
&runtime_id,
|
||||
WorkerSpawnRequest {
|
||||
requested_worker_name: Some(display_name.clone()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
@@ -6798,7 +6931,7 @@ async fn create_workspace_worker(
|
||||
)?;
|
||||
Ok(Json(record_browser_worker_spawn(
|
||||
&api,
|
||||
request.runtime_id,
|
||||
runtime_id,
|
||||
display_name,
|
||||
selected_working_directory_id,
|
||||
result,
|
||||
@@ -8949,6 +9082,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary
|
||||
workspace: WorkerWorkspaceSummary {
|
||||
visibility: "backend_registry".to_string(),
|
||||
identity: record.workspace_id.clone(),
|
||||
workspace_id: Some(record.workspace_id.clone()),
|
||||
},
|
||||
profile: record.profile.clone(),
|
||||
implementation: WorkerImplementationSummary {
|
||||
@@ -9729,7 +9863,7 @@ impl IntoResponse for ApiError {
|
||||
Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
@@ -10008,6 +10142,107 @@ mod tests {
|
||||
assert!(!serialized.contains("materialized_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flow_source_resolution_returns_immutable_workspace_and_builtin_snapshots() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let source = r#"{
|
||||
schema_version = 1;
|
||||
name = "browser-flow";
|
||||
initial = "work";
|
||||
states = {
|
||||
work = {
|
||||
instructions = "Implement and validate the requested change.";
|
||||
transitions = {
|
||||
done = { target = "done"; condition = "The work is complete."; };
|
||||
};
|
||||
};
|
||||
done = { instructions = ""; terminal = true; };
|
||||
};
|
||||
}"#;
|
||||
let stored = api
|
||||
.store
|
||||
.put_flow_source_for_kind(
|
||||
&api.config.workspace_id,
|
||||
FlowSourceKind::Workspace,
|
||||
"flows/browser-flow.dcdl",
|
||||
source,
|
||||
"2026-08-06T00:00:00Z",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let Json(resolved) = scoped_resolve_flow_source(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
}),
|
||||
Json(FlowSourceResolveRequest {
|
||||
selector: "workspace:browser-flow".parse().unwrap(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.flow_id, stored.flow_id);
|
||||
assert_eq!(resolved.revision, stored.revision);
|
||||
assert_eq!(resolved.content_digest, stored.content_digest);
|
||||
assert_eq!(resolved.definition.name, "browser-flow");
|
||||
|
||||
let Json(builtin) = scoped_resolve_flow_source(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
}),
|
||||
Json(FlowSourceResolveRequest {
|
||||
selector: "builtin:coder-review".parse().unwrap(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(builtin.definition.name, "coder-review");
|
||||
assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
|
||||
assert_eq!(builtin.flow_id, "builtin:coder-review");
|
||||
assert_eq!(builtin.revision, 1);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_flow_sources(&api.config.workspace_id)
|
||||
.unwrap(),
|
||||
vec![stored],
|
||||
"built-in resolution must not mutate Workspace source authority",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_source_auth_rejects_cross_workspace_mutation() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
Json(BrowserCreateWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: "Scoped Worker".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
initial_text: String::new(),
|
||||
working_directory: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-yoi-runtime-id",
|
||||
axum::http::HeaderValue::from_str(&created.worker_ref.runtime_id).unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-yoi-worker-id",
|
||||
axum::http::HeaderValue::from_str(&created.worker_ref.worker_id).unwrap(),
|
||||
);
|
||||
let error =
|
||||
authenticate_worker_mutation_source(&api, "other-workspace", &headers).unwrap_err();
|
||||
assert!(matches!(error, Error::WorkerSourceIdentity(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_orchestrator_launch_marks_only_the_dedicated_worker() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -14375,6 +14610,81 @@ mod tests {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_flow_source_route_persists_compiled_dcdl() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let app = test_app(temp.path()).await;
|
||||
let workspace_id = test_identity().workspace_id;
|
||||
let source = r#"{
|
||||
schema_version = 1;
|
||||
name = "route-flow";
|
||||
initial = "work";
|
||||
states = {
|
||||
work = {
|
||||
instructions = "Do the work.";
|
||||
transitions = {
|
||||
done = {
|
||||
target = "done";
|
||||
condition = "The work is complete.";
|
||||
};
|
||||
};
|
||||
};
|
||||
done = { instructions = ""; terminal = true; };
|
||||
};
|
||||
}"#;
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(format!("/api/w/{workspace_id}/flows"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"path": "flows/route-flow.dcdl",
|
||||
"content": source,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/w/{workspace_id}/flows"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(format!("/api/w/{workspace_id}/flows"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"path": "flows/broken.dcdl",
|
||||
"content": "{ schema_version = 1; }",
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passkey_registration_rejects_unverified_credential_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -3,8 +3,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
@@ -139,6 +141,16 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "create Worker Workdir attachment reservations",
|
||||
apply: create_worker_workdir_attachment_reservations,
|
||||
},
|
||||
Migration {
|
||||
version: 25,
|
||||
name: "create Flow source authority",
|
||||
apply: create_flow_source_authority,
|
||||
},
|
||||
Migration {
|
||||
version: 26,
|
||||
name: "remove Backend-owned Flow runtime authority",
|
||||
apply: remove_backend_flow_runtime_authority,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
@@ -408,6 +420,31 @@ pub struct MemoryStagingResolutionRecord {
|
||||
pub resolved_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FlowSourceRecord {
|
||||
pub workspace_id: String,
|
||||
pub flow_id: String,
|
||||
pub source_kind: FlowSourceKind,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub content_digest: String,
|
||||
pub revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FlowSourceRevisionRecord {
|
||||
pub workspace_id: String,
|
||||
pub flow_id: String,
|
||||
pub revision: u64,
|
||||
pub content: String,
|
||||
pub content_digest: String,
|
||||
pub definition: CompiledFlowDefinition,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
async fn schema_version(&self) -> Result<i64>;
|
||||
@@ -417,6 +454,33 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
|
||||
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
|
||||
|
||||
fn put_flow_source_for_kind(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
source_kind: FlowSourceKind,
|
||||
path: &str,
|
||||
content: &str,
|
||||
now: &str,
|
||||
) -> Result<FlowSourceRecord>;
|
||||
fn get_flow_source_by_name(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
source_kind: FlowSourceKind,
|
||||
name: &str,
|
||||
) -> Result<Option<FlowSourceRecord>>;
|
||||
fn list_flow_sources(&self, workspace_id: &str) -> Result<Vec<FlowSourceRecord>>;
|
||||
fn get_flow_source(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
flow_id: &str,
|
||||
) -> Result<Option<FlowSourceRecord>>;
|
||||
fn get_flow_source_revision(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
flow_id: &str,
|
||||
revision: u64,
|
||||
) -> Result<Option<FlowSourceRevisionRecord>>;
|
||||
|
||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
|
||||
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
|
||||
fn get_objective(
|
||||
@@ -866,6 +930,219 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn put_flow_source_for_kind(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
source_kind: FlowSourceKind,
|
||||
path: &str,
|
||||
content: &str,
|
||||
now: &str,
|
||||
) -> Result<FlowSourceRecord> {
|
||||
if source_kind != FlowSourceKind::Workspace {
|
||||
return Err(Error::Store(
|
||||
"built-in Flow sources are resource authority and cannot be written to Workspace DB"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let name = flow_source_name(path)?;
|
||||
let definition = compile_flow_source(content).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"compile Flow source {path:?}: {:?}",
|
||||
error.diagnostics
|
||||
))
|
||||
})?;
|
||||
if definition.name != name {
|
||||
return Err(Error::Store(format!(
|
||||
"Flow source name {:?} does not match path slug {name:?}",
|
||||
definition.name
|
||||
)));
|
||||
}
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let existing = tx
|
||||
.query_row(
|
||||
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
|
||||
content_digest, revision, created_at, updated_at
|
||||
FROM flow_sources
|
||||
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
|
||||
params![workspace_id, source_kind.as_str(), name],
|
||||
read_flow_source_record,
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(existing) = existing {
|
||||
if existing.content_digest == definition.content_digest {
|
||||
tx.commit()?;
|
||||
return Ok(existing);
|
||||
}
|
||||
let revision = existing
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Store("Flow source revision overflowed".to_string()))?;
|
||||
let definition_json = serde_json::to_string(&definition)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO flow_source_revisions (
|
||||
workspace_id, flow_id, revision, content, content_digest,
|
||||
definition_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
existing.flow_id,
|
||||
revision,
|
||||
content,
|
||||
definition.content_digest,
|
||||
definition_json,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"UPDATE flow_sources
|
||||
SET path = ?4, content = ?5, content_digest = ?6,
|
||||
revision = ?7, updated_at = ?8
|
||||
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
|
||||
params![
|
||||
workspace_id,
|
||||
source_kind.as_str(),
|
||||
name,
|
||||
path,
|
||||
content,
|
||||
definition.content_digest,
|
||||
revision,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
return Ok(FlowSourceRecord {
|
||||
revision,
|
||||
path: path.to_string(),
|
||||
content: content.to_string(),
|
||||
content_digest: definition.content_digest,
|
||||
updated_at: now.to_string(),
|
||||
..existing
|
||||
});
|
||||
}
|
||||
|
||||
let flow_id = Uuid::now_v7().to_string();
|
||||
let definition_json = serde_json::to_string(&definition)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO flow_sources (
|
||||
workspace_id, flow_id, source_kind, name, path, content,
|
||||
content_digest, revision, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
flow_id,
|
||||
source_kind.as_str(),
|
||||
name,
|
||||
path,
|
||||
content,
|
||||
definition.content_digest,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO flow_source_revisions (
|
||||
workspace_id, flow_id, revision, content, content_digest,
|
||||
definition_json, created_at
|
||||
) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
flow_id,
|
||||
content,
|
||||
definition.content_digest,
|
||||
definition_json,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(FlowSourceRecord {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
flow_id,
|
||||
source_kind,
|
||||
name,
|
||||
path: path.to_string(),
|
||||
content: content.to_string(),
|
||||
content_digest: definition.content_digest,
|
||||
revision: 1,
|
||||
created_at: now.to_string(),
|
||||
updated_at: now.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn get_flow_source_by_name(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
source_kind: FlowSourceKind,
|
||||
name: &str,
|
||||
) -> Result<Option<FlowSourceRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
|
||||
content_digest, revision, created_at, updated_at
|
||||
FROM flow_sources
|
||||
WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#,
|
||||
params![workspace_id, source_kind.as_str(), name],
|
||||
read_flow_source_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn list_flow_sources(&self, workspace_id: &str) -> Result<Vec<FlowSourceRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
|
||||
content_digest, revision, created_at, updated_at
|
||||
FROM flow_sources WHERE workspace_id = ?1
|
||||
ORDER BY source_kind ASC, name ASC"#,
|
||||
)?;
|
||||
let rows = statement.query_map(params![workspace_id], read_flow_source_record)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_flow_source(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
flow_id: &str,
|
||||
) -> Result<Option<FlowSourceRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, flow_id, source_kind, name, path, content,
|
||||
content_digest, revision, created_at, updated_at
|
||||
FROM flow_sources WHERE workspace_id = ?1 AND flow_id = ?2"#,
|
||||
params![workspace_id, flow_id],
|
||||
read_flow_source_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_flow_source_revision(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
flow_id: &str,
|
||||
revision: u64,
|
||||
) -> Result<Option<FlowSourceRevisionRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, flow_id, revision, content, content_digest,
|
||||
definition_json, created_at
|
||||
FROM flow_source_revisions
|
||||
WHERE workspace_id = ?1 AND flow_id = ?2 AND revision = ?3"#,
|
||||
params![workspace_id, flow_id, revision],
|
||||
read_flow_source_revision_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
@@ -2660,6 +2937,79 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn flow_source_name(path: &str) -> Result<String> {
|
||||
let file_name = path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| Error::Store("Flow source path has no file name".to_string()))?;
|
||||
let name = file_name
|
||||
.strip_suffix(".dcdl")
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| Error::Store("Flow source path must end in .dcdl".to_string()))?;
|
||||
flow::FlowSelector::builtin(name)
|
||||
.map_err(|error| Error::Store(format!("invalid Flow source slug: {error}")))?;
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn read_flow_source_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<FlowSourceRecord> {
|
||||
let source_kind = match row.get::<_, String>(2)?.as_str() {
|
||||
"builtin" => FlowSourceKind::Builtin,
|
||||
"workspace" => FlowSourceKind::Workspace,
|
||||
other => {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid Flow source kind {other:?}").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let revision = row.get::<_, i64>(7)?;
|
||||
Ok(FlowSourceRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
flow_id: row.get(1)?,
|
||||
source_kind,
|
||||
name: row.get(3)?,
|
||||
path: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
content_digest: row.get(6)?,
|
||||
revision: u64::try_from(revision).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
7,
|
||||
rusqlite::types::Type::Integer,
|
||||
Box::new(error),
|
||||
)
|
||||
})?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_flow_source_revision_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<FlowSourceRevisionRecord> {
|
||||
let revision = row.get::<_, i64>(2)?;
|
||||
let definition_json = row.get::<_, String>(5)?;
|
||||
let definition = serde_json::from_str(&definition_json).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
|
||||
})?;
|
||||
Ok(FlowSourceRevisionRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
flow_id: row.get(1)?,
|
||||
revision: u64::try_from(revision).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Integer,
|
||||
Box::new(error),
|
||||
)
|
||||
})?,
|
||||
content: row.get(3)?,
|
||||
content_digest: row.get(4)?,
|
||||
definition,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceRecord> {
|
||||
Ok(WorkspaceRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
@@ -3623,6 +3973,52 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations (
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_flow_source_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE flow_sources (
|
||||
workspace_id TEXT NOT NULL,
|
||||
flow_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, flow_id),
|
||||
UNIQUE (workspace_id, source_kind, name),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE flow_source_revisions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
flow_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
content TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, flow_id, revision),
|
||||
FOREIGN KEY (workspace_id, flow_id)
|
||||
REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_backend_flow_runtime_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
DROP TABLE IF EXISTS flow_events;
|
||||
DROP TABLE IF EXISTS flow_transition_attempts;
|
||||
DROP TABLE IF EXISTS flow_instances;
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations",
|
||||
@@ -4182,17 +4578,54 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 24);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 26);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v26_removes_legacy_backend_flow_runtime_tables() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 25)
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
(migration.apply)(&tx).unwrap();
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![migration.version, migration.name],
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE flow_instances (instance_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE flow_transition_attempts (attempt_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 25);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 26);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_transition_attempts").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_events").unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_sqlite_and_preserves_workspace_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 26);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -4209,13 +4642,88 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 26);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_flow_sources_keep_revisions_and_builtins_stay_resources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Workspace A".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-08-06T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-06T00:00:00Z".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let workspace_source = r#"{
|
||||
schema_version = 1;
|
||||
name = "coder-review";
|
||||
initial = "work";
|
||||
states = {
|
||||
work = {
|
||||
instructions = "Workspace revision one.";
|
||||
transitions = { done = { target = "done"; condition = "Done."; }; };
|
||||
};
|
||||
done = { instructions = ""; terminal = true; };
|
||||
};
|
||||
}"#;
|
||||
let workspace = store
|
||||
.put_flow_source_for_kind(
|
||||
"workspace-a",
|
||||
FlowSourceKind::Workspace,
|
||||
"flows/coder-review.dcdl",
|
||||
workspace_source,
|
||||
"2026-08-06T00:00:01Z",
|
||||
)
|
||||
.unwrap();
|
||||
let builtin = flow::builtin_flow_source("coder-review").unwrap();
|
||||
assert_eq!(builtin.slug, workspace.name);
|
||||
assert!(
|
||||
store
|
||||
.put_flow_source_for_kind(
|
||||
"workspace-a",
|
||||
FlowSourceKind::Builtin,
|
||||
builtin.path,
|
||||
builtin.content,
|
||||
"2026-08-06T00:00:02Z",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
store.list_flow_sources("workspace-a").unwrap(),
|
||||
vec![workspace.clone()]
|
||||
);
|
||||
|
||||
let revision_two =
|
||||
workspace_source.replace("Workspace revision one.", "Workspace revision two.");
|
||||
let updated = store
|
||||
.put_flow_source_for_kind(
|
||||
"workspace-a",
|
||||
FlowSourceKind::Workspace,
|
||||
"flows/coder-review.dcdl",
|
||||
&revision_two,
|
||||
"2026-08-06T00:00:03Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.flow_id, workspace.flow_id);
|
||||
assert_eq!(updated.revision, 2);
|
||||
let pinned = store
|
||||
.get_flow_source_revision("workspace-a", &workspace.flow_id, 1)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(pinned.content, workspace_source);
|
||||
assert_eq!(pinned.definition.name, "coder-review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -4681,7 +5189,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 26);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -4870,7 +5378,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 26);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -4908,7 +5416,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 26);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -5156,7 +5664,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 24);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 26);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user