feat: runtime and UX polish
P1 features:
- System prompt injection on session resume (ThreadRecord gains system_prompt
field, ensure_engine_loaded passes it to Op::SyncSession)
- Session resume bridge: GET/POST /v1/sessions/{id}, seed_thread_from_messages
- Task detail panel with deep links (?task=<id>), timeline, tool calls
- Tauri desktop build fix (CI=true in tauri:build script)
P2 features:
- Task detail auto-refresh polling for running/queued tasks (3s interval)
- Session delete: DELETE /v1/sessions/{id} endpoint + palette delete button
- Transcript full-text search input with combined role filter + search
- Per-item copy-to-clipboard, collapse/expand for long outputs, filter chips
Polish:
- Typography scale CSS variables, skeleton loading utilities
- Panel slide-in animation for task detail, fade-up for list items
- Toast auto-dismiss (4s success, 6s error) with dismiss buttons
- Focus trap in command palette, backdrop click to close
- Escape key closes task detail panel
- ARIA improvements: role=alert on error toasts, role=listbox on palette
- Responsive breakpoints for tablet (task detail stacking, palette width)
- prefers-reduced-motion respected throughout
All validation checks pass:
- cargo test (517 tests), cargo check
- pnpm typecheck, lint (0 errors), test (36 tests)
- pnpm web:build, desktop:build
This commit is contained in:
@@ -65,3 +65,24 @@ jobs:
|
||||
run: cargo doc --no-deps
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
|
||||
frontend:
|
||||
name: Frontend (deepseek-app)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Lint
|
||||
run: pnpm deepseek-app:lint
|
||||
- name: Typecheck
|
||||
run: pnpm deepseek-app:typecheck
|
||||
- name: Test
|
||||
run: pnpm deepseek-app:test
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
Generated
+1
@@ -766,6 +766,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.0",
|
||||
|
||||
@@ -46,6 +46,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
|
||||
@@ -179,6 +179,23 @@ cargo fmt
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Standalone DeepSeek App (Web + Desktop)
|
||||
|
||||
This repo now includes a separate Codex-style app at `apps/deepseek-app` built with Next.js + Tauri.
|
||||
|
||||
```bash
|
||||
# Install frontend deps once
|
||||
pnpm install
|
||||
|
||||
# Web app
|
||||
pnpm deepseek-app:web:dev
|
||||
|
||||
# Desktop app (Tauri)
|
||||
pnpm deepseek-app:desktop:dev
|
||||
```
|
||||
|
||||
The app talks to the runtime API (`deepseek serve --http`) and desktop mode auto-starts it when unavailable.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -42,6 +42,10 @@ The event log is append-only with global monotonic `seq` for replay/resume.
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/sessions?limit=50&search=<substring>`
|
||||
- `GET /v1/workspace/status`
|
||||
- `GET /v1/skills`
|
||||
- `GET /v1/apps/mcp/servers`
|
||||
- `GET /v1/apps/mcp/tools?server=<optional>`
|
||||
|
||||
### Compatibility Stream (Single Turn)
|
||||
|
||||
@@ -80,7 +84,9 @@ Typical SSE events:
|
||||
|
||||
- `POST /v1/threads`
|
||||
- `GET /v1/threads?limit=50&include_archived=false`
|
||||
- `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false`
|
||||
- `GET /v1/threads/{id}`
|
||||
- `PATCH /v1/threads/{id}` (currently supports `{ "archived": true|false }`)
|
||||
- `POST /v1/threads/{id}/resume`
|
||||
- `POST /v1/threads/{id}/fork`
|
||||
|
||||
@@ -110,6 +116,7 @@ Notes:
|
||||
- `interrupt` returns quickly and marks `turn.interrupt_requested`.
|
||||
- Terminal turn status becomes `interrupted` only after cleanup completes.
|
||||
- Manual compaction is exposed as a turn with `context_compaction` item lifecycle events.
|
||||
- Archiving/unarchiving threads updates persisted thread state and emits `thread.updated`.
|
||||
|
||||
### Replayable Events
|
||||
|
||||
@@ -166,6 +173,25 @@ Tasks execute through the same runtime thread/turn pipeline and include:
|
||||
- runtime event count
|
||||
- timeline + tool summaries + artifact references
|
||||
|
||||
### Automations
|
||||
|
||||
- `GET /v1/automations`
|
||||
- `POST /v1/automations`
|
||||
- `GET /v1/automations/{id}`
|
||||
- `PATCH /v1/automations/{id}`
|
||||
- `DELETE /v1/automations/{id}`
|
||||
- `POST /v1/automations/{id}/run`
|
||||
- `POST /v1/automations/{id}/pause`
|
||||
- `POST /v1/automations/{id}/resume`
|
||||
- `GET /v1/automations/{id}/runs?limit=20`
|
||||
|
||||
RRULE support is intentionally constrained to:
|
||||
- hourly: `FREQ=HOURLY;INTERVAL=<hours>[;BYDAY=MO,TU,...]`
|
||||
- weekly: `FREQ=WEEKLY;BYDAY=...;BYHOUR=<0-23>;BYMINUTE=<0-59>`
|
||||
|
||||
Automations are persisted under `~/.deepseek/automations` (override with `DEEPSEEK_AUTOMATIONS_DIR`).
|
||||
Each run is executed as a normal background task and links to task/thread/turn ids.
|
||||
|
||||
## Persistence
|
||||
|
||||
Runtime store (default under task data root):
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "deepseek-tui-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"scripts": {
|
||||
"deepseek-app:web:dev": "pnpm --filter deepseek-app dev",
|
||||
"deepseek-app:web:build": "pnpm --filter deepseek-app build",
|
||||
"deepseek-app:desktop:dev": "pnpm --filter deepseek-app tauri:dev",
|
||||
"deepseek-app:desktop:build": "pnpm --filter deepseek-app tauri:build",
|
||||
"deepseek-app:lint": "pnpm --filter deepseek-app lint",
|
||||
"deepseek-app:typecheck": "pnpm --filter deepseek-app typecheck",
|
||||
"deepseek-app:test": "pnpm --filter deepseek-app test"
|
||||
}
|
||||
}
|
||||
Generated
+6375
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- apps/*
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Durable automation records and scheduler support.
|
||||
//!
|
||||
//! Automations are local-first recurring jobs that enqueue standard background
|
||||
//! tasks. This module stores automation definitions and run history under
|
||||
//! `~/.deepseek/automations` (or `DEEPSEEK_AUTOMATIONS_DIR` override).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike, Utc, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskStatus};
|
||||
|
||||
const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1;
|
||||
const CURRENT_RUN_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
const fn default_automation_schema_version() -> u32 {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
const fn default_run_schema_version() -> u32 {
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AutomationStatus {
|
||||
Active,
|
||||
Paused,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AutomationRunStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationRecord {
|
||||
#[serde(default = "default_automation_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub rrule: String,
|
||||
#[serde(default)]
|
||||
pub cwds: Vec<PathBuf>,
|
||||
pub status: AutomationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationRunRecord {
|
||||
#[serde(default = "default_run_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub automation_id: String,
|
||||
pub scheduled_for: DateTime<Utc>,
|
||||
pub status: AutomationRunStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateAutomationRequest {
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub rrule: String,
|
||||
#[serde(default)]
|
||||
pub cwds: Vec<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateAutomationRequest {
|
||||
pub name: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub rrule: Option<String>,
|
||||
pub cwds: Option<Vec<PathBuf>>,
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AutomationFrequency {
|
||||
Hourly,
|
||||
Weekly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutomationSchedule {
|
||||
Hourly {
|
||||
interval_hours: u32,
|
||||
byday: Option<Vec<Weekday>>,
|
||||
},
|
||||
Weekly {
|
||||
byday: Vec<Weekday>,
|
||||
byhour: u32,
|
||||
byminute: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl AutomationSchedule {
|
||||
pub fn parse_rrule(rrule: &str) -> Result<Self> {
|
||||
let mut parts: BTreeMap<String, String> = BTreeMap::new();
|
||||
for raw in rrule.split(';') {
|
||||
let item = raw.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = item.split_once('=') else {
|
||||
bail!("Invalid RRULE segment '{item}'");
|
||||
};
|
||||
parts.insert(k.trim().to_ascii_uppercase(), v.trim().to_ascii_uppercase());
|
||||
}
|
||||
|
||||
let freq = match parts.get("FREQ").map(String::as_str) {
|
||||
Some("HOURLY") => AutomationFrequency::Hourly,
|
||||
Some("WEEKLY") => AutomationFrequency::Weekly,
|
||||
Some(other) => bail!("Unsupported RRULE FREQ '{other}'. Supported: HOURLY and WEEKLY"),
|
||||
None => bail!("RRULE must include FREQ"),
|
||||
};
|
||||
|
||||
match freq {
|
||||
AutomationFrequency::Hourly => {
|
||||
for key in parts.keys() {
|
||||
if key != "FREQ" && key != "INTERVAL" && key != "BYDAY" {
|
||||
bail!(
|
||||
"Unsupported RRULE field '{key}' for HOURLY. Allowed: FREQ,INTERVAL,BYDAY"
|
||||
);
|
||||
}
|
||||
}
|
||||
let interval_hours = parts
|
||||
.get("INTERVAL")
|
||||
.map(|v| v.parse::<u32>())
|
||||
.transpose()
|
||||
.context("Failed to parse INTERVAL")?
|
||||
.unwrap_or(1);
|
||||
if interval_hours == 0 {
|
||||
bail!("INTERVAL must be >= 1 for HOURLY schedules");
|
||||
}
|
||||
let byday = parts
|
||||
.get("BYDAY")
|
||||
.map(|value| parse_byday(value))
|
||||
.transpose()?;
|
||||
Ok(Self::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
})
|
||||
}
|
||||
AutomationFrequency::Weekly => {
|
||||
for key in parts.keys() {
|
||||
if key != "FREQ" && key != "BYDAY" && key != "BYHOUR" && key != "BYMINUTE" {
|
||||
bail!(
|
||||
"Unsupported RRULE field '{key}' for WEEKLY. Allowed: FREQ,BYDAY,BYHOUR,BYMINUTE"
|
||||
);
|
||||
}
|
||||
}
|
||||
let byday_raw = parts
|
||||
.get("BYDAY")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYDAY"))?;
|
||||
let byday = parse_byday(byday_raw)?;
|
||||
if byday.is_empty() {
|
||||
bail!("BYDAY cannot be empty for WEEKLY schedules");
|
||||
}
|
||||
let byhour = parts
|
||||
.get("BYHOUR")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYHOUR"))?
|
||||
.parse::<u32>()
|
||||
.context("Failed to parse BYHOUR")?;
|
||||
let byminute = parts
|
||||
.get("BYMINUTE")
|
||||
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYMINUTE"))?
|
||||
.parse::<u32>()
|
||||
.context("Failed to parse BYMINUTE")?;
|
||||
|
||||
if byhour > 23 {
|
||||
bail!("BYHOUR must be between 0 and 23");
|
||||
}
|
||||
if byminute > 59 {
|
||||
bail!("BYMINUTE must be between 0 and 59");
|
||||
}
|
||||
|
||||
Ok(Self::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_after(&self, after: DateTime<Utc>) -> Result<DateTime<Utc>> {
|
||||
let local_after = after.with_timezone(&Local);
|
||||
match self {
|
||||
Self::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
} => {
|
||||
let mut candidate = local_after + Duration::hours(i64::from(*interval_hours))
|
||||
- Duration::seconds(i64::from(local_after.second()))
|
||||
- Duration::nanoseconds(i64::from(local_after.nanosecond()));
|
||||
|
||||
if let Some(days) = byday {
|
||||
for _ in 0..(24 * 21) {
|
||||
if days.contains(&candidate.weekday()) {
|
||||
return Ok(candidate.with_timezone(&Utc));
|
||||
}
|
||||
candidate += Duration::hours(i64::from(*interval_hours));
|
||||
}
|
||||
bail!("Unable to compute next HOURLY run for BYDAY filter");
|
||||
}
|
||||
|
||||
Ok(candidate.with_timezone(&Utc))
|
||||
}
|
||||
Self::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
} => {
|
||||
for day_offset in 0..15 {
|
||||
let date = local_after.date_naive() + Duration::days(i64::from(day_offset));
|
||||
if !byday.contains(&date.weekday()) {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate_naive) = date.and_hms_opt(*byhour, *byminute, 0) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(candidate) = resolve_local_datetime(candidate_naive)
|
||||
&& candidate > local_after
|
||||
{
|
||||
return Ok(candidate.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
bail!("Unable to compute next WEEKLY run");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local_datetime(naive: chrono::NaiveDateTime) -> Option<DateTime<Local>> {
|
||||
Local
|
||||
.from_local_datetime(&naive)
|
||||
.single()
|
||||
.or_else(|| Local.from_local_datetime(&naive).earliest())
|
||||
.or_else(|| Local.from_local_datetime(&naive).latest())
|
||||
}
|
||||
|
||||
fn parse_byday(value: &str) -> Result<Vec<Weekday>> {
|
||||
let mut days = Vec::new();
|
||||
for token in value.split(',') {
|
||||
let day = match token.trim().to_ascii_uppercase().as_str() {
|
||||
"MO" => Weekday::Mon,
|
||||
"TU" => Weekday::Tue,
|
||||
"WE" => Weekday::Wed,
|
||||
"TH" => Weekday::Thu,
|
||||
"FR" => Weekday::Fri,
|
||||
"SA" => Weekday::Sat,
|
||||
"SU" => Weekday::Sun,
|
||||
other => bail!("Invalid BYDAY value '{other}'"),
|
||||
};
|
||||
if !days.contains(&day) {
|
||||
days.push(day);
|
||||
}
|
||||
}
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutomationManager {
|
||||
automations_dir: PathBuf,
|
||||
runs_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AutomationManager {
|
||||
pub fn open(root: PathBuf) -> Result<Self> {
|
||||
let automations_dir = root.join("automations");
|
||||
let runs_dir = root.join("runs");
|
||||
fs::create_dir_all(&automations_dir)
|
||||
.with_context(|| format!("Failed to create {}", automations_dir.display()))?;
|
||||
fs::create_dir_all(&runs_dir)
|
||||
.with_context(|| format!("Failed to create {}", runs_dir.display()))?;
|
||||
Ok(Self {
|
||||
automations_dir,
|
||||
runs_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_location() -> Result<Self> {
|
||||
Self::open(default_automations_dir())
|
||||
}
|
||||
|
||||
fn automation_path(&self, id: &str) -> PathBuf {
|
||||
self.automations_dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
fn runs_dir_for(&self, automation_id: &str) -> PathBuf {
|
||||
self.runs_dir.join(automation_id)
|
||||
}
|
||||
|
||||
fn run_path(&self, automation_id: &str, run_id: &str) -> PathBuf {
|
||||
self.runs_dir_for(automation_id)
|
||||
.join(format!("{run_id}.json"))
|
||||
}
|
||||
|
||||
pub fn create_automation(&self, req: CreateAutomationRequest) -> Result<AutomationRecord> {
|
||||
validate_name_and_prompt(&req.name, &req.prompt)?;
|
||||
let schedule = AutomationSchedule::parse_rrule(&req.rrule)?;
|
||||
let now = Utc::now();
|
||||
let status = req.status.unwrap_or(AutomationStatus::Active);
|
||||
let next_run_at = if matches!(status, AutomationStatus::Active) {
|
||||
Some(schedule.next_after(now)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let record = AutomationRecord {
|
||||
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: req.name.trim().to_string(),
|
||||
prompt: req.prompt.trim().to_string(),
|
||||
rrule: req.rrule.trim().to_ascii_uppercase(),
|
||||
cwds: req.cwds,
|
||||
status,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
next_run_at,
|
||||
last_run_at: None,
|
||||
};
|
||||
|
||||
self.save_automation(&record)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
let path = self.automation_path(id);
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read automation {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, record: &AutomationRecord) -> Result<()> {
|
||||
write_json_atomic(&self.automation_path(&record.id), record)
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<AutomationRecord>> {
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(&self.automations_dir)
|
||||
.with_context(|| format!("Failed to read {}", self.automations_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
out.push(record);
|
||||
}
|
||||
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn update_automation(
|
||||
&self,
|
||||
id: &str,
|
||||
req: UpdateAutomationRequest,
|
||||
) -> Result<AutomationRecord> {
|
||||
let mut existing = self.get_automation(id)?;
|
||||
|
||||
if let Some(name) = req.name {
|
||||
if name.trim().is_empty() {
|
||||
bail!("Automation name cannot be empty");
|
||||
}
|
||||
existing.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(prompt) = req.prompt {
|
||||
if prompt.trim().is_empty() {
|
||||
bail!("Automation prompt cannot be empty");
|
||||
}
|
||||
existing.prompt = prompt.trim().to_string();
|
||||
}
|
||||
if let Some(rrule) = req.rrule {
|
||||
let normalized = rrule.trim().to_ascii_uppercase();
|
||||
AutomationSchedule::parse_rrule(&normalized)?;
|
||||
existing.rrule = normalized;
|
||||
if matches!(existing.status, AutomationStatus::Active) {
|
||||
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
|
||||
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
|
||||
}
|
||||
}
|
||||
if let Some(cwds) = req.cwds {
|
||||
existing.cwds = cwds;
|
||||
}
|
||||
if let Some(status) = req.status {
|
||||
existing.status = status;
|
||||
if matches!(status, AutomationStatus::Paused) {
|
||||
existing.next_run_at = None;
|
||||
} else {
|
||||
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
|
||||
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
|
||||
}
|
||||
}
|
||||
|
||||
existing.updated_at = Utc::now();
|
||||
self.save_automation(&existing)?;
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
pub fn pause_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
self.update_automation(
|
||||
id,
|
||||
UpdateAutomationRequest {
|
||||
status: Some(AutomationStatus::Paused),
|
||||
..UpdateAutomationRequest::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resume_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
self.update_automation(
|
||||
id,
|
||||
UpdateAutomationRequest {
|
||||
status: Some(AutomationStatus::Active),
|
||||
..UpdateAutomationRequest::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<AutomationRecord> {
|
||||
let existing = self.get_automation(id)?;
|
||||
let path = self.automation_path(id);
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("Failed to delete automation {}", path.display()))?;
|
||||
|
||||
let runs_dir = self.runs_dir_for(id);
|
||||
if runs_dir.exists() {
|
||||
fs::remove_dir_all(&runs_dir).with_context(|| {
|
||||
format!("Failed to delete automation runs {}", runs_dir.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
pub fn list_runs(
|
||||
&self,
|
||||
automation_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<AutomationRunRecord>> {
|
||||
let dir = self.runs_dir_for(automation_id);
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let run: AutomationRunRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation run schema v{} is newer than supported v{}",
|
||||
run.schema_version,
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
out.push(run);
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
if let Some(limit) = limit {
|
||||
out.truncate(limit);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn save_run(&self, run: &AutomationRunRecord) -> Result<()> {
|
||||
let dir = self.runs_dir_for(&run.automation_id);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
|
||||
write_json_atomic(&self.run_path(&run.automation_id, &run.id), run)
|
||||
}
|
||||
|
||||
async fn enqueue_run_task(
|
||||
&self,
|
||||
automation: &AutomationRecord,
|
||||
run: &mut AutomationRunRecord,
|
||||
task_manager: &SharedTaskManager,
|
||||
) -> Result<()> {
|
||||
let workspace = automation.cwds.first().cloned();
|
||||
|
||||
let new_task = NewTaskRequest {
|
||||
prompt: automation.prompt.clone(),
|
||||
model: None,
|
||||
workspace,
|
||||
mode: Some("agent".to_string()),
|
||||
allow_shell: Some(false),
|
||||
trust_mode: Some(false),
|
||||
auto_approve: Some(true),
|
||||
};
|
||||
|
||||
match task_manager.add_task(new_task).await {
|
||||
Ok(task) => {
|
||||
run.status = AutomationRunStatus::Running;
|
||||
run.started_at = Some(Utc::now());
|
||||
run.task_id = Some(task.id.clone());
|
||||
run.thread_id = task.thread_id.clone();
|
||||
run.turn_id = task.turn_id.clone();
|
||||
run.error = None;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
run.status = AutomationRunStatus::Failed;
|
||||
run.ended_at = Some(Utc::now());
|
||||
run.error = Some(format!("Failed to enqueue task: {err}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_now(
|
||||
&self,
|
||||
automation_id: &str,
|
||||
task_manager: &SharedTaskManager,
|
||||
) -> Result<AutomationRunRecord> {
|
||||
let mut automation = self.get_automation(automation_id)?;
|
||||
let now = Utc::now();
|
||||
let mut run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: automation.id.clone(),
|
||||
scheduled_for: now,
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
self.enqueue_run_task(&automation, &mut run, task_manager)
|
||||
.await?;
|
||||
self.save_run(&run)?;
|
||||
|
||||
automation.updated_at = Utc::now();
|
||||
if matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Completed
|
||||
| AutomationRunStatus::Failed
|
||||
| AutomationRunStatus::Canceled
|
||||
) {
|
||||
automation.last_run_at = run.ended_at.or(Some(Utc::now()));
|
||||
}
|
||||
self.save_automation(&automation)?;
|
||||
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub async fn scheduler_tick(&self, task_manager: &SharedTaskManager) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let mut automations = self.list_automations()?;
|
||||
|
||||
for automation in &mut automations {
|
||||
if !matches!(automation.status, AutomationStatus::Active) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
|
||||
if automation.next_run_at.is_none() {
|
||||
automation.next_run_at = Some(schedule.next_after(now)?);
|
||||
automation.updated_at = now;
|
||||
self.save_automation(automation)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let due_at = automation.next_run_at.expect("checked above");
|
||||
if due_at > now {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: if a run already exists for this schedule slot, skip enqueue and
|
||||
// advance next_run_at.
|
||||
let existing_for_slot = self
|
||||
.list_runs(&automation.id, Some(25))?
|
||||
.into_iter()
|
||||
.any(|run| run.scheduled_for == due_at);
|
||||
|
||||
if existing_for_slot {
|
||||
automation.next_run_at = Some(schedule.next_after(due_at)?);
|
||||
automation.updated_at = now;
|
||||
self.save_automation(automation)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: automation.id.clone(),
|
||||
scheduled_for: due_at,
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
self.enqueue_run_task(automation, &mut run, task_manager)
|
||||
.await?;
|
||||
self.save_run(&run)?;
|
||||
|
||||
automation.updated_at = now;
|
||||
automation.next_run_at = Some(schedule.next_after(due_at)?);
|
||||
self.save_automation(automation)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reconcile_run_statuses(&self, task_manager: &SharedTaskManager) -> Result<()> {
|
||||
let automations = self.list_automations()?;
|
||||
for automation in automations {
|
||||
let runs = self.list_runs(&automation.id, Some(100))?;
|
||||
for mut run in runs {
|
||||
if !matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Queued | AutomationRunStatus::Running
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(task_id) = run.task_id.clone() else {
|
||||
continue;
|
||||
};
|
||||
let task = match task_manager.get_task(&task_id).await {
|
||||
Ok(task) => task,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
run.thread_id = task.thread_id.clone();
|
||||
run.turn_id = task.turn_id.clone();
|
||||
|
||||
let mut changed = false;
|
||||
match task.status {
|
||||
TaskStatus::Queued => {
|
||||
if !matches!(run.status, AutomationRunStatus::Queued) {
|
||||
run.status = AutomationRunStatus::Queued;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
TaskStatus::Running => {
|
||||
if !matches!(run.status, AutomationRunStatus::Running) {
|
||||
run.status = AutomationRunStatus::Running;
|
||||
changed = true;
|
||||
}
|
||||
if run.started_at.is_none() {
|
||||
run.started_at = Some(task.started_at.unwrap_or_else(Utc::now));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
TaskStatus::Completed => {
|
||||
run.status = AutomationRunStatus::Completed;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
run.error = None;
|
||||
changed = true;
|
||||
}
|
||||
TaskStatus::Failed => {
|
||||
run.status = AutomationRunStatus::Failed;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
run.error = task.error.clone();
|
||||
changed = true;
|
||||
}
|
||||
TaskStatus::Canceled => {
|
||||
run.status = AutomationRunStatus::Canceled;
|
||||
run.started_at = run.started_at.or(task.started_at);
|
||||
run.ended_at = task.ended_at.or(Some(Utc::now()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.save_run(&run)?;
|
||||
if matches!(
|
||||
run.status,
|
||||
AutomationRunStatus::Completed
|
||||
| AutomationRunStatus::Failed
|
||||
| AutomationRunStatus::Canceled
|
||||
) {
|
||||
let mut updated_automation = self.get_automation(&automation.id)?;
|
||||
updated_automation.last_run_at = run.ended_at.or(Some(Utc::now()));
|
||||
updated_automation.updated_at = Utc::now();
|
||||
self.save_automation(&updated_automation)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name_and_prompt(name: &str, prompt: &str) -> Result<()> {
|
||||
if name.trim().is_empty() {
|
||||
bail!("Automation name is required");
|
||||
}
|
||||
if prompt.trim().is_empty() {
|
||||
bail!("Automation prompt is required");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(value)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, content).with_context(|| format!("Failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path).with_context(|| {
|
||||
format!(
|
||||
"Failed to move temporary file {} to {}",
|
||||
tmp.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn default_automations_dir() -> PathBuf {
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_AUTOMATIONS_DIR") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".deepseek").join("automations"))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek").join("automations"))
|
||||
}
|
||||
|
||||
pub type SharedAutomationManager = Arc<Mutex<AutomationManager>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutomationSchedulerConfig {
|
||||
pub tick_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for AutomationSchedulerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tick_interval_secs: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(
|
||||
automations: SharedAutomationManager,
|
||||
task_manager: SharedTaskManager,
|
||||
cancel: CancellationToken,
|
||||
config: AutomationSchedulerConfig,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let interval = config.tick_interval_secs.max(5);
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
{
|
||||
let manager = automations.lock().await;
|
||||
if let Err(err) = manager.scheduler_tick(&task_manager).await {
|
||||
tracing::warn!("automation scheduler tick failed: {err}");
|
||||
}
|
||||
if let Err(err) = manager.reconcile_run_statuses(&task_manager).await {
|
||||
tracing::warn!("automation reconcile failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = sleep(std::time::Duration::from_secs(interval)) => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_hourly_rrule() {
|
||||
let parsed =
|
||||
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=2;BYDAY=MO,TU").expect("parse");
|
||||
match parsed {
|
||||
AutomationSchedule::Hourly {
|
||||
interval_hours,
|
||||
byday,
|
||||
} => {
|
||||
assert_eq!(interval_hours, 2);
|
||||
assert_eq!(byday.expect("byday").len(), 2);
|
||||
}
|
||||
_ => panic!("expected hourly"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_weekly_rrule() {
|
||||
let parsed =
|
||||
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=30")
|
||||
.expect("parse");
|
||||
match parsed {
|
||||
AutomationSchedule::Weekly {
|
||||
byday,
|
||||
byhour,
|
||||
byminute,
|
||||
} => {
|
||||
assert_eq!(byday.len(), 2);
|
||||
assert_eq!(byhour, 9);
|
||||
assert_eq!(byminute, 30);
|
||||
}
|
||||
_ => panic!("expected weekly"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_rrule_fields() {
|
||||
let err =
|
||||
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYSECOND=5").expect_err("should fail");
|
||||
assert!(err.to_string().contains("Unsupported RRULE field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_automation_and_runs() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
|
||||
|
||||
let created = manager
|
||||
.create_automation(CreateAutomationRequest {
|
||||
name: "Delete me".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
status: Some(AutomationStatus::Active),
|
||||
})
|
||||
.expect("create");
|
||||
|
||||
let run = AutomationRunRecord {
|
||||
schema_version: CURRENT_RUN_SCHEMA_VERSION,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
automation_id: created.id.clone(),
|
||||
scheduled_for: Utc::now(),
|
||||
status: AutomationRunStatus::Queued,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
ended_at: None,
|
||||
task_id: None,
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
error: None,
|
||||
};
|
||||
manager.save_run(&run).expect("save run");
|
||||
assert!(manager.runs_dir_for(&created.id).exists());
|
||||
|
||||
manager
|
||||
.delete_automation(&created.id)
|
||||
.expect("delete automation");
|
||||
|
||||
assert!(manager.get_automation(&created.id).is_err());
|
||||
assert!(!manager.runs_dir_for(&created.id).exists());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use tempfile::NamedTempFile;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
mod audit;
|
||||
mod automation_manager;
|
||||
mod client;
|
||||
mod command_safety;
|
||||
mod commands;
|
||||
|
||||
+1002
-7
File diff suppressed because it is too large
Load Diff
+183
-3
@@ -23,7 +23,7 @@ use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
|
||||
use crate::core::ops::Op;
|
||||
use crate::models::{
|
||||
ContentBlock, Message, Usage, compaction_message_threshold_for_model,
|
||||
ContentBlock, Message, SystemPrompt, Usage, compaction_message_threshold_for_model,
|
||||
compaction_threshold_for_model,
|
||||
};
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
@@ -93,6 +93,8 @@ pub struct ThreadRecord {
|
||||
pub latest_response_bookmark: Option<String>,
|
||||
#[serde(default)]
|
||||
pub archived: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -491,6 +493,13 @@ pub struct CreateThreadRequest {
|
||||
pub auto_approve: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub archived: bool,
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateThreadRequest {
|
||||
pub archived: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -656,6 +665,7 @@ impl RuntimeThreadManager {
|
||||
latest_turn_id: None,
|
||||
latest_response_bookmark: None,
|
||||
archived: req.archived,
|
||||
system_prompt: req.system_prompt,
|
||||
};
|
||||
self.store.save_thread(&thread)?;
|
||||
self.emit_event(
|
||||
@@ -690,6 +700,42 @@ impl RuntimeThreadManager {
|
||||
.with_context(|| format!("Thread not found: {id}"))
|
||||
}
|
||||
|
||||
pub async fn update_thread(&self, id: &str, req: UpdateThreadRequest) -> Result<ThreadRecord> {
|
||||
if req.archived.is_none() {
|
||||
bail!("At least one thread field is required");
|
||||
}
|
||||
|
||||
let mut thread = self.get_thread(id).await?;
|
||||
let mut changed = false;
|
||||
|
||||
if let Some(archived) = req.archived
|
||||
&& thread.archived != archived
|
||||
{
|
||||
thread.archived = archived;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
thread.updated_at = Utc::now();
|
||||
self.store.save_thread(&thread)?;
|
||||
self.emit_event(
|
||||
&thread.id,
|
||||
None,
|
||||
None,
|
||||
"thread.updated",
|
||||
json!({
|
||||
"thread": thread.clone(),
|
||||
"changes": {
|
||||
"archived": thread.archived
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
pub async fn get_thread_detail(&self, id: &str) -> Result<ThreadDetail> {
|
||||
let thread = self.get_thread(id).await?;
|
||||
let turns = self.store.list_turns_for_thread(id)?;
|
||||
@@ -759,6 +805,131 @@ impl RuntimeThreadManager {
|
||||
Ok(forked)
|
||||
}
|
||||
|
||||
/// Seed a thread with messages from a saved session so subsequent turns
|
||||
/// continue with the prior conversation context.
|
||||
pub async fn seed_thread_from_messages(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
messages: &[Message],
|
||||
) -> Result<()> {
|
||||
let mut thread = self.get_thread(thread_id).await?;
|
||||
let now = Utc::now();
|
||||
|
||||
let mut user_buf: Vec<String> = Vec::new();
|
||||
let mut pending_pairs: Vec<(String, Option<String>)> = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
let text = msg
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if msg.role == "user" {
|
||||
user_buf.push(text);
|
||||
} else if msg.role == "assistant" {
|
||||
let user_text = if user_buf.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
user_buf.drain(..).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
pending_pairs.push((user_text, Some(text)));
|
||||
}
|
||||
}
|
||||
if !user_buf.is_empty() {
|
||||
let user_text = user_buf.drain(..).collect::<Vec<_>>().join("\n");
|
||||
pending_pairs.push((user_text, None));
|
||||
}
|
||||
|
||||
for (user_text, assistant_text) in pending_pairs {
|
||||
let turn_id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let summary = if user_text.len() > SUMMARY_LIMIT {
|
||||
format!("{}...", &user_text[..SUMMARY_LIMIT.saturating_sub(3)])
|
||||
} else {
|
||||
user_text.clone()
|
||||
};
|
||||
let mut item_ids = Vec::new();
|
||||
|
||||
if !user_text.is_empty() {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
self.store.save_item(&TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: item_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::UserMessage,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: summary.clone(),
|
||||
detail: Some(user_text),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(now),
|
||||
ended_at: Some(now),
|
||||
})?;
|
||||
item_ids.push(item_id);
|
||||
}
|
||||
|
||||
if let Some(assistant_text) = assistant_text {
|
||||
let asst_summary = if assistant_text.len() > SUMMARY_LIMIT {
|
||||
format!(
|
||||
"{}...",
|
||||
&assistant_text[..SUMMARY_LIMIT.saturating_sub(3)]
|
||||
)
|
||||
} else {
|
||||
assistant_text.clone()
|
||||
};
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
self.store.save_item(&TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: item_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::AgentMessage,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
summary: asst_summary,
|
||||
detail: Some(assistant_text),
|
||||
artifact_refs: Vec::new(),
|
||||
started_at: Some(now),
|
||||
ended_at: Some(now),
|
||||
})?;
|
||||
item_ids.push(item_id);
|
||||
}
|
||||
|
||||
self.store.save_turn(&TurnRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: turn_id.clone(),
|
||||
thread_id: thread_id.to_string(),
|
||||
status: RuntimeTurnStatus::Completed,
|
||||
input_summary: summary,
|
||||
created_at: now,
|
||||
started_at: Some(now),
|
||||
ended_at: Some(now),
|
||||
duration_ms: Some(0),
|
||||
usage: None,
|
||||
error: None,
|
||||
item_ids,
|
||||
steer_count: 0,
|
||||
})?;
|
||||
|
||||
thread.latest_turn_id = Some(turn_id);
|
||||
thread.updated_at = now;
|
||||
}
|
||||
|
||||
self.store.save_thread(&thread)?;
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
None,
|
||||
None,
|
||||
"thread.updated",
|
||||
json!({ "thread": thread, "reason": "session_resume" }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_turn(&self, thread_id: &str, req: StartTurnRequest) -> Result<TurnRecord> {
|
||||
let prompt = req.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
@@ -1180,11 +1351,15 @@ impl RuntimeThreadManager {
|
||||
|
||||
let turns = self.store.list_turns_for_thread(&thread.id)?;
|
||||
let session_messages = self.reconstruct_messages_from_turns(&turns)?;
|
||||
if !session_messages.is_empty() {
|
||||
let sys_prompt = thread
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|s| SystemPrompt::Text(s.clone()));
|
||||
if !session_messages.is_empty() || sys_prompt.is_some() {
|
||||
engine
|
||||
.send(Op::SyncSession {
|
||||
messages: session_messages,
|
||||
system_prompt: None,
|
||||
system_prompt: sys_prompt,
|
||||
model: thread.model.clone(),
|
||||
workspace: thread.workspace.clone(),
|
||||
})
|
||||
@@ -2030,6 +2205,7 @@ mod tests {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -2114,6 +2290,7 @@ mod tests {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -2234,6 +2411,7 @@ mod tests {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -2324,6 +2502,7 @@ mod tests {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -2429,6 +2608,7 @@ mod tests {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -348,6 +348,7 @@ impl TaskExecutor for EngineTaskExecutor {
|
||||
trust_mode: Some(task.trust_mode),
|
||||
auto_approve: Some(task.auto_approve),
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user