fix(code-queue): add workdirs contract checks
This commit is contained in:
@@ -395,6 +395,7 @@ function codeQueueK3sServiceIdForRequest(method: string, targetPath: string): st
|
||||
function codeQueueMasterControlPath(method: string, targetPath: string): boolean {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
if (targetPath === "/" || targetPath === "/health" || targetPath === "/live" || targetPath === "/logs") return true;
|
||||
if (targetPath === "/api/workdirs" || /^\/api\/workdirs\/[^/]+\/[^/]+\/.+$/u.test(targetPath)) return true;
|
||||
if (targetPath === "/api/queue-claim-move/self-test") return true;
|
||||
if (targetPath === "/api/queues" || targetPath === "/api/queues/merge") return true;
|
||||
if (/^\/api\/queues\/[^/]+(?:\/merge)?$/u.test(targetPath)) return true;
|
||||
@@ -404,6 +405,38 @@ function codeQueueMasterControlPath(method: string, targetPath: string): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
function codeQueueCompatWorkdirsResponse(service: MicroserviceConfig, status: number, contentType: string, bodyText: string): Response | null {
|
||||
if (status !== 404) return null;
|
||||
let body: any = null;
|
||||
try {
|
||||
body = JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (body?.path !== "/api/workdirs") return null;
|
||||
const providerId = "D601";
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
workdirs: [{
|
||||
providerId,
|
||||
executionMode: "default",
|
||||
path: "/workspace",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
source: "backend-core-compat",
|
||||
}],
|
||||
defaultProviderId: providerId,
|
||||
defaultWorkdir: "/workspace",
|
||||
remoteDefaultWorkdir: "/home/ubuntu",
|
||||
source: "backend-core-compat-code-queue-workdirs",
|
||||
compat: {
|
||||
reason: "code-queue-mgr version does not yet expose /api/workdirs",
|
||||
upstreamStatus: status,
|
||||
upstreamContentType: contentType,
|
||||
},
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1026,7 +1059,24 @@ async function fetchMicroserviceUpstreamResponse(
|
||||
): Promise<Response> {
|
||||
if (service.id === "code-queue" && codeQueueMasterControlPath(method, targetPath)) {
|
||||
const mgr = microserviceById("code-queue-mgr");
|
||||
if (mgr !== null) return directMicroserviceResponse(mgr, method, targetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
|
||||
if (mgr !== null) {
|
||||
const response = await directMicroserviceResponse(mgr, method, targetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
|
||||
if (method.toUpperCase() === "GET" && targetPath === "/api/workdirs") {
|
||||
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
|
||||
const responseBody = await response.text();
|
||||
const compat = codeQueueCompatWorkdirsResponse(service, response.status, contentType, responseBody);
|
||||
if (compat !== null) return compat;
|
||||
return new Response(responseBody, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"x-unidesk-proxy-mode": response.headers.get("x-unidesk-proxy-mode") ?? "direct",
|
||||
"x-unidesk-response-truncated": response.headers.get("x-unidesk-response-truncated") ?? "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
logger("warn", "code_queue_mgr_missing_fallback_to_d601", { method, targetPath });
|
||||
}
|
||||
if (isK3sctlManagedMicroservice(service)) {
|
||||
|
||||
@@ -154,6 +154,47 @@ fn code_queue_scheduler_only_path(method: &Method, target_path: &str) -> bool {
|
||||
&& !(target_path == "/" || target_path == "/health")
|
||||
}
|
||||
|
||||
fn code_queue_fallback_workdirs_response(
|
||||
service: &MicroserviceConfig,
|
||||
status: u16,
|
||||
content_type: &str,
|
||||
body_text: &str,
|
||||
) -> Option<Response> {
|
||||
if status != 404 {
|
||||
return None;
|
||||
}
|
||||
let body = serde_json::from_str::<Value>(body_text).ok()?;
|
||||
if body.get("path").and_then(Value::as_str) != Some("/api/workdirs") {
|
||||
return None;
|
||||
}
|
||||
let provider_id = service.provider_id.clone();
|
||||
let fallback = json!({
|
||||
"ok": true,
|
||||
"workdirs": [{
|
||||
"providerId": provider_id,
|
||||
"executionMode": "default",
|
||||
"path": "/workspace",
|
||||
"createdAt": null,
|
||||
"updatedAt": null,
|
||||
"source": "backend-core-compat"
|
||||
}],
|
||||
"defaultProviderId": provider_id,
|
||||
"defaultWorkdir": "/workspace",
|
||||
"remoteDefaultWorkdir": "/home/ubuntu",
|
||||
"source": "backend-core-compat-code-queue-workdirs",
|
||||
"compat": {
|
||||
"reason": "code-queue-mgr version does not yet expose /api/workdirs",
|
||||
"upstreamStatus": status,
|
||||
"upstreamContentType": content_type
|
||||
}
|
||||
});
|
||||
Some(response_with_body(
|
||||
200,
|
||||
"application/json; charset=utf-8",
|
||||
Body::from(serde_json::to_string(&fallback).unwrap_or_else(|_| "{\"ok\":true,\"workdirs\":[]}".to_string())),
|
||||
))
|
||||
}
|
||||
|
||||
fn path_matches_task_detail(path: &str) -> bool {
|
||||
path.strip_prefix("/api/tasks/")
|
||||
.is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
|
||||
@@ -926,7 +967,7 @@ async fn fetch_microservice_upstream_response(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return direct_microservice_response(
|
||||
let response = direct_microservice_response(
|
||||
&direct_code_queue_mgr_service(service, None),
|
||||
method,
|
||||
target_path,
|
||||
@@ -935,6 +976,18 @@ async fn fetch_microservice_upstream_response(
|
||||
body_text,
|
||||
)
|
||||
.await;
|
||||
if method == Method::GET && target_path == "/api/workdirs" {
|
||||
let (status, content_type, response_body) = response_to_text(response).await.unwrap_or((
|
||||
502,
|
||||
"text/plain".to_string(),
|
||||
"failed to read code-queue workdirs response".to_string(),
|
||||
));
|
||||
if let Some(fallback) = code_queue_fallback_workdirs_response(service, status, &content_type, &response_body) {
|
||||
return fallback;
|
||||
}
|
||||
return response_with_body(status, &content_type, Body::from(response_body));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
if is_k3sctl_managed_microservice(service) {
|
||||
return k3sctl_adapter_microservice_response(
|
||||
|
||||
@@ -18,6 +18,7 @@ const MAX_BODY_BYTES: usize = 512 * 1024;
|
||||
const MAX_LIST_LIMIT: i64 = 200;
|
||||
const MAX_OUTPUT_PAGE_LIMIT: usize = 500;
|
||||
const MAX_PREVIEW_CHARS: usize = 6000;
|
||||
const WORKDIR_MAX_CHARS: usize = 512;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
@@ -347,6 +348,14 @@ fn ensure_schema(state: &AppState) -> Result<(), String> {
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS unidesk_code_queue_workdirs (
|
||||
provider_id TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL DEFAULT 'default',
|
||||
path TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (provider_id, execution_mode, path)
|
||||
);
|
||||
ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS queue_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS provider_id TEXT NOT NULL DEFAULT 'main-server';
|
||||
ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default';
|
||||
@@ -355,6 +364,7 @@ fn ensure_schema(state: &AppState) -> Result<(), String> {
|
||||
ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_injection JSONB;
|
||||
ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
|
||||
ALTER TABLE unidesk_code_queue_queues ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE unidesk_code_queue_workdirs ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default';
|
||||
CREATE INDEX IF NOT EXISTS idx_code_queue_tasks_list ON unidesk_code_queue_tasks (status, updated_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_queue_tasks_queue_updated ON unidesk_code_queue_tasks (queue_id, updated_at DESC, id DESC);
|
||||
",
|
||||
@@ -370,6 +380,7 @@ fn ensure_schema(state: &AppState) -> Result<(), String> {
|
||||
&[&DEFAULT_QUEUE_ID.to_string(), &DEFAULT_QUEUE_ID.to_string(), &now],
|
||||
)
|
||||
.map_err(|error| error_detail(&error))?;
|
||||
upsert_default_workdir(&mut client, state, &now)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1093,6 +1104,185 @@ fn update_queue(state: &AppState, queue_id_value: &str, request: &mut Request) -
|
||||
))
|
||||
}
|
||||
|
||||
fn default_workdir_record(state: &AppState, timestamp: &str) -> Result<Value, String> {
|
||||
let provider_id = normalize_workdir_provider_id(state, &state.config.default_provider_id);
|
||||
let path = normalize_workdir_path(state, &provider_id, &state.config.default_workdir)?;
|
||||
Ok(json!({
|
||||
"providerId": provider_id,
|
||||
"executionMode": "default",
|
||||
"path": path,
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp
|
||||
}))
|
||||
}
|
||||
|
||||
fn workdir_key(record: &Value) -> String {
|
||||
format!(
|
||||
"{}\u{0}{}\u{0}{}",
|
||||
record.get("providerId").and_then(Value::as_str).unwrap_or(""),
|
||||
record.get("executionMode").and_then(Value::as_str).unwrap_or("default"),
|
||||
record.get("path").and_then(Value::as_str).unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
fn sorted_workdir_records(records: Vec<Value>) -> Vec<Value> {
|
||||
let mut keyed = std::collections::BTreeMap::<String, Value>::new();
|
||||
for record in records {
|
||||
keyed.insert(workdir_key(&record), record);
|
||||
}
|
||||
keyed.into_values().collect()
|
||||
}
|
||||
|
||||
fn upsert_default_workdir(client: &mut Client, state: &AppState, timestamp: &str) -> Result<(), String> {
|
||||
let record = default_workdir_record(state, timestamp)?;
|
||||
let provider_id = record.get("providerId").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let execution_mode = record.get("executionMode").and_then(Value::as_str).unwrap_or("default").to_string();
|
||||
let path = record.get("path").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
client
|
||||
.execute(
|
||||
"
|
||||
INSERT INTO unidesk_code_queue_workdirs (provider_id, execution_mode, path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::text::timestamptz, $4::text::timestamptz)
|
||||
ON CONFLICT (provider_id, execution_mode, path) DO UPDATE SET updated_at = EXCLUDED.updated_at
|
||||
",
|
||||
&[&provider_id, &execution_mode, &path, ×tamp],
|
||||
)
|
||||
.map_err(|error| error_detail(&error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_workdirs(state: &AppState) -> Result<Vec<Value>, String> {
|
||||
let mut client = db_client(state, "unidesk-code-queue-mgr-rust-workdirs")?;
|
||||
let rows = client
|
||||
.query(
|
||||
"
|
||||
SELECT provider_id, execution_mode, path, created_at::text, updated_at::text
|
||||
FROM unidesk_code_queue_workdirs
|
||||
ORDER BY provider_id ASC, execution_mode ASC, path ASC
|
||||
",
|
||||
&[],
|
||||
)
|
||||
.map_err(|error| error_detail(&error))?;
|
||||
let mut records = Vec::new();
|
||||
if let Ok(record) = default_workdir_record(state, &now_iso()) {
|
||||
records.push(record);
|
||||
}
|
||||
for row in rows {
|
||||
let provider_id = normalize_workdir_provider_id(state, row.get::<_, &str>(0));
|
||||
let execution_mode_raw: &str = row.get(1);
|
||||
let execution_mode = match execution_mode_raw {
|
||||
"windows-native" => "windows-native",
|
||||
_ => "default",
|
||||
};
|
||||
let path_raw: &str = row.get(2);
|
||||
let path = normalize_workdir_path(state, &provider_id, path_raw)?;
|
||||
records.push(json!({
|
||||
"providerId": provider_id,
|
||||
"executionMode": execution_mode,
|
||||
"path": path,
|
||||
"createdAt": row.get::<_, String>(3),
|
||||
"updatedAt": row.get::<_, String>(4)
|
||||
}));
|
||||
}
|
||||
Ok(sorted_workdir_records(records))
|
||||
}
|
||||
|
||||
fn list_workdirs(state: &AppState, url: &RequestUrl) -> Result<(Value, u16), (Value, u16)> {
|
||||
let provider_filter = query_param(url, "providerId")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| normalize_workdir_provider_id(state, &value));
|
||||
let mode_filter = query_param(url, "executionMode")
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| normalize_execution_mode(Some(&json!(value))));
|
||||
let records = load_workdirs(state).map_err(|error| (json!({ "ok": false, "error": error }), 500))?;
|
||||
let workdirs = records
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
if let Some(provider_id) = &provider_filter {
|
||||
if record.get("providerId").and_then(Value::as_str) != Some(provider_id.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(mode) = &mode_filter {
|
||||
if record.get("executionMode").and_then(Value::as_str) != Some(mode.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok((json!({
|
||||
"ok": true,
|
||||
"workdirs": workdirs,
|
||||
"defaultProviderId": state.config.default_provider_id,
|
||||
"defaultWorkdir": state.config.default_workdir,
|
||||
"remoteDefaultWorkdir": state.config.remote_default_workdir,
|
||||
"source": "code-queue-mgr-rust-postgres"
|
||||
}), 200))
|
||||
}
|
||||
|
||||
fn create_workdir(state: &AppState, request: &mut Request) -> Result<(Value, u16), (Value, u16)> {
|
||||
let body = read_json_body(request)?;
|
||||
let provider_id = normalize_workdir_provider_id(state, body.get("providerId").and_then(Value::as_str).unwrap_or(""));
|
||||
let execution_mode = normalize_execution_mode(body.get("executionMode"));
|
||||
let raw_path = body
|
||||
.get("path")
|
||||
.or_else(|| body.get("cwd"))
|
||||
.or_else(|| body.get("workdir"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let path = normalize_workdir_path(state, &provider_id, raw_path).map_err(|error| (json!({ "ok": false, "error": error }), 400))?;
|
||||
let now = now_iso();
|
||||
let mut client = db_client(state, "unidesk-code-queue-mgr-rust-workdir-create").map_err(|error| (json!({ "ok": false, "error": error }), 500))?;
|
||||
client
|
||||
.execute(
|
||||
"
|
||||
INSERT INTO unidesk_code_queue_workdirs (provider_id, execution_mode, path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::text::timestamptz, $4::text::timestamptz)
|
||||
ON CONFLICT (provider_id, execution_mode, path) DO UPDATE SET updated_at = EXCLUDED.updated_at
|
||||
",
|
||||
&[&provider_id, &execution_mode, &path, &now],
|
||||
)
|
||||
.map_err(|error| (json!({ "ok": false, "error": error_detail(&error) }), 500))?;
|
||||
let workdir = json!({ "providerId": provider_id, "executionMode": execution_mode, "path": path, "createdAt": now, "updatedAt": now });
|
||||
let workdirs = load_workdirs(state).map_err(|error| (json!({ "ok": false, "error": error }), 500))?;
|
||||
Ok((json!({
|
||||
"ok": true,
|
||||
"workdir": workdir,
|
||||
"workdirs": workdirs,
|
||||
"ensure": { "ok": true, "skipped": true, "reason": "code-queue-mgr records workdir menu options only; provider directories are created by the execution plane" }
|
||||
}), 201))
|
||||
}
|
||||
|
||||
fn delete_workdir(state: &AppState, provider_id_value: &str, execution_mode_value: &str, path_value: &str) -> Result<(Value, u16), (Value, u16)> {
|
||||
let provider_id = normalize_workdir_provider_id(state, provider_id_value);
|
||||
let execution_mode = normalize_execution_mode(Some(&json!(execution_mode_value)));
|
||||
let path = normalize_workdir_path(state, &provider_id, path_value).map_err(|error| (json!({ "ok": false, "error": error }), 400))?;
|
||||
let mut client = db_client(state, "unidesk-code-queue-mgr-rust-workdir-delete").map_err(|error| (json!({ "ok": false, "error": error }), 500))?;
|
||||
let rows = client
|
||||
.query(
|
||||
"
|
||||
DELETE FROM unidesk_code_queue_workdirs
|
||||
WHERE provider_id = $1 AND execution_mode = $2 AND path = $3
|
||||
RETURNING provider_id, execution_mode, path, created_at::text, updated_at::text
|
||||
",
|
||||
&[&provider_id, &execution_mode, &path],
|
||||
)
|
||||
.map_err(|error| (json!({ "ok": false, "error": error_detail(&error) }), 500))?;
|
||||
let Some(row) = rows.first() else {
|
||||
return Ok((json!({ "ok": false, "error": "workdir not found" }), 404));
|
||||
};
|
||||
let deleted = json!({
|
||||
"providerId": row.get::<_, String>(0),
|
||||
"executionMode": row.get::<_, String>(1),
|
||||
"path": row.get::<_, String>(2),
|
||||
"createdAt": row.get::<_, String>(3),
|
||||
"updatedAt": row.get::<_, String>(4)
|
||||
});
|
||||
let workdirs = load_workdirs(state).map_err(|error| (json!({ "ok": false, "error": error }), 500))?;
|
||||
Ok((json!({ "ok": true, "deleted": deleted, "workdirs": workdirs }), 200))
|
||||
}
|
||||
|
||||
fn normalize_provider_id(state: &AppState, value: Option<&Value>) -> String {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
@@ -1102,6 +1292,20 @@ fn normalize_provider_id(state: &AppState, value: Option<&Value>) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_workdir_provider_id(state: &AppState, value: &str) -> String {
|
||||
let text = value.trim();
|
||||
if !text.is_empty()
|
||||
&& text.len() <= 64
|
||||
&& text
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
|
||||
{
|
||||
text.to_string()
|
||||
} else {
|
||||
state.config.default_provider_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cwd(state: &AppState, provider_id: &str, value: Option<&Value>) -> String {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
@@ -1111,6 +1315,34 @@ fn normalize_cwd(state: &AppState, provider_id: &str, value: Option<&Value>) ->
|
||||
.unwrap_or_else(|| if provider_id == "main-server" { state.config.default_workdir.clone() } else { state.config.remote_default_workdir.clone() })
|
||||
}
|
||||
|
||||
fn default_workdir_for_provider(state: &AppState, provider_id: &str) -> String {
|
||||
if provider_id == state.config.default_provider_id {
|
||||
state.config.default_workdir.clone()
|
||||
} else {
|
||||
state.config.remote_default_workdir.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_workdir_path(state: &AppState, provider_id: &str, value: &str) -> Result<String, String> {
|
||||
let raw = value.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("workdir path is required".to_string());
|
||||
}
|
||||
if raw.chars().count() > WORKDIR_MAX_CHARS {
|
||||
return Err(format!("workdir path must be {WORKDIR_MAX_CHARS} characters or fewer"));
|
||||
}
|
||||
if raw.contains('\0') {
|
||||
return Err("workdir path contains an invalid character".to_string());
|
||||
}
|
||||
let path = if raw.starts_with('/') {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("{}/{}", default_workdir_for_provider(state, provider_id).trim_end_matches('/'), raw)
|
||||
};
|
||||
let trimmed = path.trim_end_matches('/').to_string();
|
||||
Ok(if trimmed.is_empty() { "/".to_string() } else { trimmed })
|
||||
}
|
||||
|
||||
fn normalize_execution_mode(value: Option<&Value>) -> String {
|
||||
match value.and_then(Value::as_str).unwrap_or("").trim().to_ascii_lowercase().as_str() {
|
||||
"windows-native" | "windows" | "win32" | "native-windows" => "windows-native".to_string(),
|
||||
@@ -1924,7 +2156,7 @@ fn health(state: &AppState) -> (Value, u16) {
|
||||
"readPath": "column-first"
|
||||
},
|
||||
"endpoints": {
|
||||
"control": ["/api/queues", "/api/tasks", "/api/tasks/:id/(move|retry|read|edit)", "/api/queue-claim-move/self-test"],
|
||||
"control": ["/api/queues", "/api/tasks", "/api/tasks/:id/(move|retry|read|edit)", "/api/workdirs", "/api/queue-claim-move/self-test"],
|
||||
"traceRead": ["/api/tasks/overview", "/api/tasks/:id/summary", "/api/tasks/:id/trace-summary", "/api/tasks/:id/trace-steps", "/api/tasks/:id/output"]
|
||||
}
|
||||
});
|
||||
@@ -2015,6 +2247,29 @@ fn handle_request(state: &AppState, request: &mut Request) -> (Value, u16) {
|
||||
if path == "/api/queue-claim-move/self-test" && (method == Method::Get || method == Method::Post) {
|
||||
return (json!({ "ok": true, "runtime": "rust", "check": "route-present", "claimMoveSafety": "conditional update requires unclaimed queued/retry_wait task" }), 200);
|
||||
}
|
||||
if path == "/api/workdirs" && method == Method::Get {
|
||||
return list_workdirs(state, &url).unwrap_or_else(|response| response);
|
||||
}
|
||||
if path == "/api/workdirs" && method == Method::Post {
|
||||
return create_workdir(state, request).unwrap_or_else(|response| response);
|
||||
}
|
||||
if method == Method::Delete {
|
||||
if let Some(rest) = path.strip_prefix("/api/workdirs/") {
|
||||
let mut parts = rest.splitn(3, '/');
|
||||
let provider_id = parts.next().unwrap_or_default();
|
||||
let execution_mode = parts.next().unwrap_or_default();
|
||||
let path_value = parts.next().unwrap_or_default();
|
||||
if !provider_id.is_empty() && !execution_mode.is_empty() && !path_value.is_empty() {
|
||||
return delete_workdir(
|
||||
state,
|
||||
&percent_decode(provider_id),
|
||||
&percent_decode(execution_mode),
|
||||
&percent_decode(path_value),
|
||||
)
|
||||
.unwrap_or_else(|response| response);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(response) = route_active_control(path, &method) {
|
||||
return response;
|
||||
}
|
||||
@@ -2344,4 +2599,13 @@ mod tests {
|
||||
assert!(preview.get("text").and_then(Value::as_str).unwrap_or("").contains("...<truncated>"));
|
||||
assert!(!preview.get("text").and_then(Value::as_str).unwrap_or("").contains(marker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_workdir_contract_includes_workspace() {
|
||||
let state = test_state();
|
||||
let record = default_workdir_record(&state, "2026-01-01T00:00:00.000Z").expect("default workdir");
|
||||
assert_eq!(record.get("providerId").and_then(Value::as_str), Some("D601"));
|
||||
assert_eq!(record.get("executionMode").and_then(Value::as_str), Some("default"));
|
||||
assert_eq!(record.get("path").and_then(Value::as_str), Some("/workspace"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ interface QueueRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface WorkdirRecord {
|
||||
providerId: string;
|
||||
executionMode: CodeExecutionMode;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface DailyTaskStatsBucket {
|
||||
date: string;
|
||||
executedTasks: number;
|
||||
@@ -275,6 +283,7 @@ const codeQueueEnvironmentHint = [
|
||||
"如果当前 Code Queue Docker 容器缺少完成任务所需的环境、系统包或语言依赖,可以先在容器内临时安装以推进当前任务;同时必须把该依赖补到 `src/components/microservices/code-queue/Dockerfile`,让后续任务重建镜像后可直接使用。",
|
||||
].join("\n");
|
||||
const maxTaskAttempts = 99;
|
||||
const workdirMaxLength = 512;
|
||||
const defaultCodeModels = ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4", "minimax-m2.7"];
|
||||
const codeExecutionModes: CodeExecutionMode[] = ["default", "windows-native"];
|
||||
const codexStatsTimeZone = "Asia/Shanghai";
|
||||
@@ -523,10 +532,140 @@ function normalizeProviderId(value: unknown): string {
|
||||
return text.length > 0 ? text : config.defaultProviderId;
|
||||
}
|
||||
|
||||
function normalizeWorkdirProviderId(value: unknown): string {
|
||||
const text = typeof value === "string" ? value.trim() : "";
|
||||
return /^[A-Za-z0-9_.-]{1,64}$/u.test(text) ? text : config.defaultProviderId;
|
||||
}
|
||||
|
||||
function normalizeCwd(providerId: string, value: unknown): string {
|
||||
const text = typeof value === "string" && value.trim().length > 0 ? value.trim() : "";
|
||||
if (text.length > 0) return text;
|
||||
return providerId === "main-server" ? config.defaultWorkdir : config.remoteDefaultWorkdir;
|
||||
return providerId === config.defaultProviderId ? config.defaultWorkdir : config.remoteDefaultWorkdir;
|
||||
}
|
||||
|
||||
function defaultWorkdirForProvider(providerId: string): string {
|
||||
return providerId === config.defaultProviderId ? config.defaultWorkdir : config.remoteDefaultWorkdir;
|
||||
}
|
||||
|
||||
function normalizeWorkdirPath(value: unknown, providerId: string): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
if (raw.length === 0) throw new Error("workdir path is required");
|
||||
if (raw.length > workdirMaxLength) throw new Error(`workdir path must be ${workdirMaxLength} characters or fewer`);
|
||||
if (raw.includes("\u0000")) throw new Error("workdir path contains an invalid character");
|
||||
if (raw.startsWith("/")) return raw.replace(/\/+$/u, "") || "/";
|
||||
return `${defaultWorkdirForProvider(providerId).replace(/\/+$/u, "")}/${raw}`.replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
function defaultWorkdirRecords(timestamp = nowIso()): WorkdirRecord[] {
|
||||
const providerId = normalizeWorkdirProviderId(config.defaultProviderId);
|
||||
return [{
|
||||
providerId,
|
||||
executionMode: "default",
|
||||
path: normalizeWorkdirPath(config.defaultWorkdir, providerId),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}];
|
||||
}
|
||||
|
||||
function workdirRecordKey(record: Pick<WorkdirRecord, "providerId" | "executionMode" | "path">): string {
|
||||
return `${record.providerId}\u0000${record.executionMode}\u0000${record.path}`;
|
||||
}
|
||||
|
||||
function mergeWorkdirRecords(records: WorkdirRecord[]): WorkdirRecord[] {
|
||||
const byKey = new Map<string, WorkdirRecord>();
|
||||
for (const record of records) {
|
||||
byKey.set(workdirRecordKey(record), record);
|
||||
}
|
||||
return Array.from(byKey.values()).sort((left, right) => {
|
||||
const providerDelta = left.providerId.localeCompare(right.providerId);
|
||||
if (providerDelta !== 0) return providerDelta;
|
||||
const modeDelta = left.executionMode.localeCompare(right.executionMode);
|
||||
if (modeDelta !== 0) return modeDelta;
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkdirRecords(): Promise<WorkdirRecord[]> {
|
||||
const rows = await mgrSql<Array<{ provider_id: string; execution_mode: string; path: string; created_at: Date | string; updated_at: Date | string }>>`
|
||||
SELECT provider_id, execution_mode, path, created_at, updated_at
|
||||
FROM unidesk_code_queue_workdirs
|
||||
ORDER BY provider_id ASC, execution_mode ASC, path ASC
|
||||
`;
|
||||
const records = rows.map((row) => {
|
||||
const providerId = normalizeWorkdirProviderId(row.provider_id);
|
||||
return {
|
||||
providerId,
|
||||
executionMode: normalizeExecutionMode(row.execution_mode),
|
||||
path: normalizeWorkdirPath(row.path, providerId),
|
||||
createdAt: timestampToIso(row.created_at) ?? nowIso(),
|
||||
updatedAt: timestampToIso(row.updated_at) ?? nowIso(),
|
||||
};
|
||||
});
|
||||
return mergeWorkdirRecords([...defaultWorkdirRecords(), ...records]);
|
||||
}
|
||||
|
||||
async function upsertWorkdir(record: WorkdirRecord): Promise<void> {
|
||||
await mgrSql`
|
||||
INSERT INTO unidesk_code_queue_workdirs (provider_id, execution_mode, path, created_at, updated_at)
|
||||
VALUES (${record.providerId}, ${record.executionMode}, ${record.path}, ${record.createdAt}, ${record.updatedAt})
|
||||
ON CONFLICT (provider_id, execution_mode, path) DO UPDATE SET
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
}
|
||||
|
||||
function filteredWorkdirRecords(records: WorkdirRecord[], url: URL): WorkdirRecord[] {
|
||||
const providerIdParam = url.searchParams.get("providerId");
|
||||
const executionModeParam = url.searchParams.get("executionMode");
|
||||
const providerId = providerIdParam === null || providerIdParam.trim().length === 0 ? null : normalizeWorkdirProviderId(providerIdParam);
|
||||
const executionMode = executionModeParam === null || executionModeParam.trim().length === 0 ? null : normalizeExecutionMode(executionModeParam);
|
||||
return records.filter((record) => {
|
||||
if (providerId !== null && record.providerId !== providerId) return false;
|
||||
if (executionMode !== null && record.executionMode !== executionMode) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function listWorkdirs(url: URL): Promise<Response> {
|
||||
const records = await loadWorkdirRecords();
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
workdirs: filteredWorkdirRecords(records, url),
|
||||
defaultProviderId: config.defaultProviderId,
|
||||
defaultWorkdir: config.defaultWorkdir,
|
||||
remoteDefaultWorkdir: config.remoteDefaultWorkdir,
|
||||
source: "code-queue-mgr-postgres",
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkdir(req: Request): Promise<Response> {
|
||||
const body = asRecord(await readJson(req)) ?? {};
|
||||
const providerId = normalizeWorkdirProviderId(body.providerId);
|
||||
const executionMode = normalizeExecutionMode(body.executionMode);
|
||||
const path = normalizeWorkdirPath(body.path ?? body.cwd ?? body.workdir, providerId);
|
||||
const now = nowIso();
|
||||
const workdir: WorkdirRecord = { providerId, executionMode, path, createdAt: now, updatedAt: now };
|
||||
await upsertWorkdir(workdir);
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
workdir,
|
||||
workdirs: await loadWorkdirRecords(),
|
||||
ensure: { ok: true, skipped: true, reason: "code-queue-mgr records workdir menu options only; provider directories are created by the execution plane" },
|
||||
}, 201);
|
||||
}
|
||||
|
||||
async function deleteWorkdir(providerIdValue: string, executionModeValue: string, pathValue: string): Promise<Response> {
|
||||
const providerId = normalizeWorkdirProviderId(providerIdValue);
|
||||
const executionMode = normalizeExecutionMode(executionModeValue);
|
||||
const path = normalizeWorkdirPath(pathValue, providerId);
|
||||
const rows = await mgrSql<WorkdirRecord[]>`
|
||||
DELETE FROM unidesk_code_queue_workdirs
|
||||
WHERE provider_id = ${providerId}
|
||||
AND execution_mode = ${executionMode}
|
||||
AND path = ${path}
|
||||
RETURNING provider_id AS "providerId", execution_mode AS "executionMode", path, created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
`;
|
||||
if (rows.length === 0) return jsonResponse({ ok: false, error: "workdir not found" }, 404);
|
||||
return jsonResponse({ ok: true, deleted: rows[0], workdirs: await loadWorkdirRecords() });
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
@@ -1145,6 +1284,16 @@ async function ensureSchema(): Promise<void> {
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
`;
|
||||
await mgrSql`
|
||||
CREATE TABLE IF NOT EXISTS unidesk_code_queue_workdirs (
|
||||
provider_id TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL DEFAULT 'default',
|
||||
path TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (provider_id, execution_mode, path)
|
||||
)
|
||||
`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS queue_id TEXT NOT NULL DEFAULT 'default'`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS provider_id TEXT NOT NULL DEFAULT 'main-server'`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'`;
|
||||
@@ -1153,8 +1302,10 @@ async function ensureSchema(): Promise<void> {
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_injection JSONB`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_queues ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''`;
|
||||
await mgrSql`ALTER TABLE unidesk_code_queue_workdirs ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'`;
|
||||
const now = nowIso();
|
||||
await upsertQueue({ id: config.defaultQueueId, name: config.defaultQueueId, createdAt: now, updatedAt: now });
|
||||
await Promise.all(defaultWorkdirRecords(now).map((record) => upsertWorkdir(record)));
|
||||
schemaReady = true;
|
||||
schemaLastError = null;
|
||||
}
|
||||
@@ -2639,7 +2790,7 @@ async function route(req: Request): Promise<Response> {
|
||||
noDockerSocket: true,
|
||||
},
|
||||
endpoints: {
|
||||
control: ["/api/queues", "/api/tasks", "/api/tasks/:id/(move|retry|read|edit)", "/api/queue-claim-move/self-test"],
|
||||
control: ["/api/queues", "/api/tasks", "/api/tasks/:id/(move|retry|read|edit)", "/api/workdirs", "/api/queue-claim-move/self-test"],
|
||||
traceRead: ["/api/tasks/overview", "/api/tasks/:id/summary", "/api/tasks/:id/trace-summary", "/api/tasks/:id/trace-steps", "/api/tasks/:id/output"],
|
||||
},
|
||||
}, schemaReady ? 200 : 503);
|
||||
@@ -2649,6 +2800,10 @@ async function route(req: Request): Promise<Response> {
|
||||
if (!schemaReady) return jsonResponse({ ok: false, error: "code-queue-mgr database schema is not ready", schemaLastError }, 503);
|
||||
return jsonResponse(await runQueueClaimMoveSelfTest(), 200);
|
||||
}
|
||||
if (url.pathname === "/api/workdirs" && req.method === "GET") return await listWorkdirs(url);
|
||||
if (url.pathname === "/api/workdirs" && req.method === "POST") return await createWorkdir(req);
|
||||
const workdirMatch = url.pathname.match(/^\/api\/workdirs\/([^/]+)\/([^/]+)\/(.+)$/u);
|
||||
if (workdirMatch !== null && req.method === "DELETE") return await deleteWorkdir(decodeURIComponent(workdirMatch[1] ?? ""), decodeURIComponent(workdirMatch[2] ?? ""), decodeURIComponent(workdirMatch[3] ?? ""));
|
||||
const activeControl = routeActiveControl(url.pathname, req.method);
|
||||
if (activeControl !== null) return jsonResponse(activeControl.body, activeControl.status);
|
||||
if (url.pathname === "/api/queues" && req.method === "GET") {
|
||||
|
||||
@@ -255,6 +255,56 @@ spec:
|
||||
bun install --frozen-lockfile
|
||||
(cd src && bun install --frozen-lockfile)
|
||||
UNIDESK_D601_RUST_CHECK=1 bun scripts/cli.ts check --full --rust
|
||||
cargo build --manifest-path src/components/microservices/code-queue-mgr/Cargo.toml --release
|
||||
CQ_MGR_ID="$(printf '%s' "$(params.revision)" | tr -cd 'A-Za-z0-9_.-' | cut -c1-48)"
|
||||
CQ_MGR_DB="$(workspaces.source.path)/code-queue-mgr-workdirs-postgres"
|
||||
rm -rf "$CQ_MGR_DB"
|
||||
mkdir -p "$CQ_MGR_DB"
|
||||
docker rm -f "code-queue-mgr-workdirs-$CQ_MGR_ID" >/dev/null 2>&1 || true
|
||||
docker run -d --name "code-queue-mgr-workdirs-$CQ_MGR_ID" \
|
||||
-p 127.0.0.1::5432 \
|
||||
-e POSTGRES_USER=unidesk_ci \
|
||||
-e POSTGRES_PASSWORD=unidesk_ci_password \
|
||||
-e POSTGRES_DB=unidesk_ci \
|
||||
-v "$CQ_MGR_DB:/var/lib/postgresql/data" \
|
||||
postgres:16-alpine >/tmp/code-queue-mgr-workdirs-postgres.cid
|
||||
CQ_MGR_DB_PORT="$(docker inspect "code-queue-mgr-workdirs-$CQ_MGR_ID" --format '{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}')"
|
||||
cleanup() {
|
||||
docker rm -f "code-queue-mgr-workdirs-$CQ_MGR_ID" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
for _ in $(seq 1 60); do
|
||||
if docker exec "code-queue-mgr-workdirs-$CQ_MGR_ID" pg_isready -U unidesk_ci -d unidesk_ci >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
CQ_MGR_PORT="$(python3 - <<'PY'
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()
|
||||
PY
|
||||
)"
|
||||
DATABASE_URL="postgres://unidesk_ci:unidesk_ci_password@127.0.0.1:$CQ_MGR_DB_PORT/unidesk_ci" \
|
||||
HOST=127.0.0.1 \
|
||||
PORT="$CQ_MGR_PORT" \
|
||||
CODE_QUEUE_MAIN_PROVIDER_ID=D601 \
|
||||
CODE_QUEUE_WORKDIR=/workspace \
|
||||
CODE_QUEUE_REMOTE_WORKDIR=/home/ubuntu \
|
||||
src/components/microservices/code-queue-mgr/target/release/code-queue-mgr >"$(workspaces.source.path)/code-queue-mgr-workdirs.log" 2>&1 &
|
||||
cq_mgr_pid="$!"
|
||||
trap 'kill "$cq_mgr_pid" >/dev/null 2>&1 || true; cleanup' EXIT
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS "http://127.0.0.1:$CQ_MGR_PORT/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
workdirs_json="$(curl -fsS "http://127.0.0.1:$CQ_MGR_PORT/api/workdirs")"
|
||||
printf '%s\n' "$workdirs_json"
|
||||
WORKDIRS_JSON="$workdirs_json" bun -e 'const body=JSON.parse(process.env.WORKDIRS_JSON||"{}"); if (body.ok !== true || !Array.isArray(body.workdirs) || !body.workdirs.some((item)=>item && item.path === "/workspace" && item.providerId === "D601" && item.executionMode === "default")) { console.error(JSON.stringify(body)); process.exit(1); }'
|
||||
---
|
||||
apiVersion: tekton.dev/v1
|
||||
kind: Task
|
||||
|
||||
Reference in New Issue
Block a user