Compare commits

...
32 changed files with 1449 additions and 126 deletions
+12 -5
View File
@@ -1,5 +1,6 @@
import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { debounce } from "@yaakapp-internal/lib";
import { gitMutations } from "@yaakapp-internal/git";
import type { GitStatus } from "@yaakapp-internal/git";
@@ -131,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
if (!didFocus) filterRef.current?.focus();
}, []);
// Focus any new sidebar models when created
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
if (!isSidebarLeafModel(payload.model)) return;
if (!(payload.change.type === "upsert" && payload.change.created)) return;
treeRef.current?.selectItem(payload.model.id, true);
// Focus new sidebar models created by the user in this window. Writes from other
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
// the selection.
useListenToTauriEvent<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== getCurrentWebviewWindow().label) continue;
if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true);
}
});
useEffect(() => {
@@ -16,6 +16,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { revealInFinderText } from "../lib/reveal";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
@@ -90,6 +91,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
],
},
];
+6 -4
View File
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
listen<ModelPayload[]>("model_writes", async (event) => {
for (const payload of event.payload) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFontSizeOnDocument(payload.model.interfaceFontSize);
}
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
+6 -4
View File
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
);
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFonts(event.payload.model);
listen<ModelPayload[]>("model_writes", async (event) => {
for (const payload of event.payload) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFonts(payload.model);
}
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFonts(settings)));
+22 -13
View File
@@ -7,24 +7,33 @@ import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (payload.change.type !== "upsert") return;
.listen<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
const changedIds: string[] = [];
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.type !== "window")
) {
wasUpdatedExternally(payload.model.id);
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.type !== "window")
) {
changedIds.push(payload.model.id);
}
}
if (changedIds.length > 0) wasUpdatedExternally(changedIds);
})
.catch(console.error);
export function wasUpdatedExternally(changedRequestId: string) {
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
jotaiStore.set(requestUpdateKeyAtom, (m) => {
const next = { ...m };
for (const id of ids) next[id] = generateId();
return next;
});
}
export function useRequestUpdateKey(requestId: string | null) {
+2 -2
View File
@@ -33,8 +33,8 @@ const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
* simply add long-lived subscribers for the lifetime of the app.
*/
function initModelListeners() {
listenToTauriEvent<ModelPayload>("model_write", (p) => {
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
listenToTauriEvent<ModelPayload[]>("model_writes", (p) => {
if (p.payload.some((payload) => isModelRelevant(payload.model))) syncAfterModelWrite();
});
}
+7 -5
View File
@@ -46,11 +46,13 @@ async function configureThemeAndShow() {
}
// Listen for settings changes, the re-compute theme
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
const model = event.payload.model.model;
if (model !== "settings" && model !== "plugin") return;
listen<ModelPayload[]>("model_writes", async (event) => {
const relevant = event.payload.some(
(p) =>
p.change.type === "upsert" &&
(p.model.model === "settings" || p.model.model === "plugin"),
);
if (!relevant) return;
await configureThemeAndShow();
}).catch(console.error);
@@ -0,0 +1,172 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
# Use Yaak
<!--
Managed by the Yaak CLI. `yaak agent install` replaces this file wholesale on
every run, so local edits are lost. To add your own guidance, write a separate
skill or use your tool's project instructions instead.
yaak-cli-version: __YAAK_CLI_VERSION__
-->
Yaak is a desktop API client. The `yaak` CLI reads and writes the **same local
database as the desktop app**, so anything you create shows up in the app
immediately, and vice versa. There is no server and no sign-in: `yaak auth` is
only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
**This skill deliberately does not list fields, body types, auth strategies, or
template functions.** The user's CLI version and installed plugins decide what
exists, so any list written here would eventually be wrong. Ask the CLI:
```bash
yaak --help # commands, plus agent hints at the bottom
yaak <command> --help # flags for one command
yaak request schema http --pretty # full request model, with guidance per field
yaak template-function list [filter] # template functions from installed plugins
yaak template-function show <name> # one function's arguments
```
`request schema http` is generated from the real model and merges in the auth
strategies contributed by plugins, so it is the authoritative answer for what a
request payload may contain and what each auth strategy needs. `workspace`,
`environment`, and `folder` have `schema` subcommands too.
Read the relevant schema before writing a JSON payload you are not certain of.
That is faster than a failed send, and it stays correct as Yaak changes.
## Resource model
- **Workspace** (`wk_…`) is the top-level container.
- **Folder** (`fl_…`) groups requests and can nest. Folders carry headers and
authentication that child requests inherit, which is the usual way to apply
one token to a whole group.
- **Request** (`rq_…`) is a single HTTP, gRPC, or WebSocket request. The CLI can
currently only create and send HTTP ones.
- **Environment** (`ev_…`) holds variables. Each workspace has a base
environment plus any number of sub-environments; a sub-environment overrides
base variables of the same name and is chosen per send with `-e`.
- **Cookie jar** (`cj_…`) stores cookies per workspace. The oldest is used by
default, so this normally needs no attention.
IDs are prefix-typed, so you can always tell what an ID refers to. Commands that
take a workspace ID infer it when exactly one workspace exists.
## Getting oriented
```bash
yaak --version || npm install -g @yaakapp/cli
yaak workspace list
```
Pick the workspace matching the user's project before changing anything, and
create one only when nothing fits.
### Skill freshness
The CLI writes this skill, so an upgraded CLI can leave it behind. That failure
is silent: nothing errors, the skill just stops mentioning things the CLI can
now do. Check once per session, alongside the commands above:
```bash
yaak --help 2>&1 | grep -A2 "Agent tooling:"
```
If it reports the skill is out of date, run `yaak agent install` and tell the
user to restart their coding tool. This session keeps running on the old copy
until they do, so finish the current request either way. Check once and do not
re-run it after acting.
**When the CLI and this skill disagree, the CLI is right.**
## Core workflows
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
target. Now `yaak -e ev_staging send <wk_id>` runs everything against staging.
**Chain instead of shell-plumbing.** A request can read another request's
response directly, and Yaak sends the dependency first if it needs to:
```
${[ response.body.path(request='rq_login', path='$.token') ]}
```
Run `yaak template-function show response.body.path` for its arguments,
including how to control when the upstream request re-sends. Chain when a
request genuinely depends on another's response; to merely run requests in
order, `yaak send <fl_id>` already does that.
**Run a set.** `yaak send` accepts a folder or workspace ID, with `--fail-fast`
and `--parallel`. Workspace and request IDs survive an export/import, so a
committed `yaak export` plus `--data-dir ./.yaak` gives a runnable suite in CI.
## Reading results
A plain send writes only the response body to stdout. Yaak also stores every
response, so the reliable way to see what happened is to ask afterwards rather
than to parse the send output:
```bash
yaak response show rq_abc123 # latest response for a request, as JSON
yaak response list rq_abc123 # its history, newest first
yaak response body rq_abc123 # just the body
```
`response show` gives status, reason, timing, headers, the final URL, and any
transport error. Pass a response ID for a specific one.
`-v` on a send prints the same information prefixed `*`, `>`, and `<`, with the
body after the last `<` header line:
```bash
yaak -v request send rq_abc123 2>&1 | grep '^< HTTP'
```
That works, but `response show` is still better when you need to act on the
result, since it gives you fields rather than text to parse.
Exit code 1 means the send did not complete: an unresolved template variable, an
unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like
`curl`, a 404 or 500 exits 0, and a folder of requests that all return 500
reports success. Never tell the user an API is healthy based on a clean exit;
check the status.
## Execution rules
1. Resolve the workspace before mutating, and prefer an existing one.
2. Read the schema rather than guessing field names, auth fields, or body shapes.
3. `update` takes a JSON merge patch keyed by `id`: send only what changes, and
note that arrays are replaced wholesale, not merged.
4. Deletes need `--yes` in a non-interactive shell. Confirm with the user first.
5. Never write a real secret into an environment variable on the user's behalf.
Reference one and let them fill in the value.
6. Verify what you built by sending it, and report the real HTTP status.
+115 -6
View File
@@ -1,17 +1,17 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
pub const AGENT_HINTS: &str = r#"Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)"#;
#[derive(Parser)]
#[command(name = "yaak")]
#[command(about = "Yaak CLI - API client from the command line")]
#[command(version = crate::version::cli_version())]
#[command(disable_help_subcommand = true)]
#[command(after_help = r#"Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)
"#)]
pub struct Cli {
/// Use a custom data directory
#[arg(long, global = true)]
@@ -39,6 +39,9 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Install Yaak skills for AI coding agents
Agent(AgentArgs),
/// Authentication commands
Auth(AuthArgs),
@@ -82,6 +85,105 @@ pub enum Commands {
/// Environment commands
Environment(EnvironmentArgs),
/// Template function commands
#[command(alias = "func")]
TemplateFunction(TemplateFunctionArgs),
/// Response commands
Response(ResponseArgs),
}
#[derive(Args)]
pub struct ResponseArgs {
#[command(subcommand)]
pub command: ResponseCommands,
}
#[derive(Subcommand)]
pub enum ResponseCommands {
/// List stored responses for a request or workspace
List {
/// Request or workspace ID (optional when exactly one workspace exists)
id: Option<String>,
/// Maximum number of responses to return, newest first
#[arg(long)]
limit: Option<u64>,
},
/// Show a response as JSON, including status, timing, and headers
Show {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Write a stored response body to stdout
Body {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Delete stored responses for a request or workspace
Delete {
/// Request or workspace ID
id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Args)]
pub struct TemplateFunctionArgs {
#[command(subcommand)]
pub command: TemplateFunctionCommands,
}
#[derive(Subcommand)]
pub enum TemplateFunctionCommands {
/// List template functions provided by installed plugins
List {
/// Only show functions whose name contains this text
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
/// Show a template function's arguments as JSON
Show {
/// Template function name (for example: response.body.path)
name: String,
/// Pretty-print JSON output
#[arg(long)]
pretty: bool,
},
}
#[derive(Args)]
pub struct AgentArgs {
#[command(subcommand)]
pub command: AgentCommands,
}
#[derive(Subcommand)]
pub enum AgentCommands {
/// Install the Yaak skill so coding agents know how to drive the CLI
#[command(alias = "update", alias = "add")]
Install {
/// Install for specific agents instead of all detected ones
#[arg(long = "agent", value_name = "AGENT")]
agent: Option<Vec<String>>,
},
/// Remove the Yaak skill
#[command(alias = "uninstall", alias = "rm")]
Remove {
/// Remove for specific agents instead of all detected ones
#[arg(long = "agent", value_name = "AGENT")]
agent: Option<Vec<String>>,
},
}
#[derive(Args)]
@@ -328,6 +430,13 @@ pub enum FolderCommands {
workspace_id: Option<String>,
},
/// Output JSON schema for folder create/update payloads
Schema {
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Show a folder as JSON
Show {
/// Folder ID
+269
View File
@@ -0,0 +1,269 @@
use crate::cli::{AgentArgs, AgentCommands};
use crate::ui;
use crate::version;
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::{Path, PathBuf};
static SKILL_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills/use-yaak");
const SKILL_NAME: &str = "use-yaak";
/// Substituted at install time so the installed skill records which CLI wrote it,
/// letting `--help` tell the user when an upgrade has left their copy behind.
const VERSION_PLACEHOLDER: &str = "__YAAK_CLI_VERSION__";
const VERSION_MARKER: &str = "yaak-cli-version:";
type CommandResult<T = ()> = std::result::Result<T, String>;
/// A coding tool that reads skills from a directory in the user's home.
struct Target {
/// Display name used in output.
label: &'static str,
/// Directory holding all skills for this tool (`…/skills`).
skills_dir: PathBuf,
}
pub fn run(args: AgentArgs) -> i32 {
let result = match args.command {
AgentCommands::Install { agent } => install(agent),
AgentCommands::Remove { agent } => remove(agent),
};
match result {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
fn install(agent: Option<Vec<String>>) -> CommandResult {
let targets = resolve_targets(agent)?;
let mut installed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
match write_skill(&dir) {
Ok(()) => {
installed += 1;
ui::success(&format!("{} -> {}", target.label, dir.display()));
}
Err(error) => ui::warning_stderr(&format!("{}: {}", target.label, error)),
}
}
if installed == 0 {
return Err("Failed to install the Yaak skill anywhere".to_string());
}
ui::info("Restart your coding tool to pick up the skill");
Ok(())
}
fn remove(agent: Option<Vec<String>>) -> CommandResult {
let targets = resolve_targets(agent)?;
let mut removed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
if !dir.exists() {
continue;
}
match fs::remove_dir_all(&dir) {
Ok(()) => {
removed += 1;
ui::success(&format!("Removed {}", dir.display()));
}
Err(error) => {
ui::warning_stderr(&format!("Failed to remove {}: {error}", dir.display()))
}
}
}
if removed == 0 {
ui::info("No Yaak skill was installed");
}
Ok(())
}
/// The skill directory belongs to the CLI, so every install overwrites what is there
/// and prunes what this version no longer ships. Preserving local edits would be worse
/// than losing them: an edited file would be skipped by every future install and go
/// stale forever, against a CLI that keeps changing.
///
/// NOTE: atomicity is per file, not for the install as a whole. That is total today
/// because the skill is a single file, but adding a second one would mean a reader
/// could catch a mix of old and new files. Replacing the directory as a unit is not an
/// option (`rename` refuses a non-empty destination), so a multi-file skill would need
/// the published path to become a symlink swapped between versioned directories.
fn write_skill(dir: &Path) -> CommandResult {
fs::create_dir_all(dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
let mut written = Vec::new();
for file in walk(&SKILL_DIR) {
let relative = file.path().to_path_buf();
let destination = dir.join(&relative);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
let contents = stamp_version(file.contents());
write_atomic(&destination, &contents)?;
written.push(relative);
}
prune_unshipped(dir, &written);
Ok(())
}
/// Write via a sibling temp file and rename over the destination. `rename` replaces an
/// existing file atomically, so a reader sees either the old copy or the new one, and a
/// crash part way through cannot leave a half-written skill behind.
fn write_atomic(destination: &Path, contents: &[u8]) -> CommandResult {
let parent = destination
.parent()
.ok_or_else(|| format!("Invalid skill path {}", destination.display()))?;
let name = destination
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("Invalid skill path {}", destination.display()))?;
let temp = parent.join(format!(".{name}.tmp-{}", std::process::id()));
fs::write(&temp, contents)
.map_err(|e| format!("Failed to write {}: {e}", destination.display()))?;
match fs::rename(&temp, destination) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp);
Err(format!("Failed to write {}: {error}", destination.display()))
}
}
}
/// Stamp the running version into the skill so it carries its own provenance.
fn stamp_version(contents: &[u8]) -> Vec<u8> {
match std::str::from_utf8(contents) {
Ok(text) => text.replace(VERSION_PLACEHOLDER, version::cli_version()).into_bytes(),
Err(_) => contents.to_vec(),
}
}
/// Drop anything this version does not ship, so files an older version wrote (or a
/// leftover temp file) cannot linger beside the refreshed skill.
fn prune_unshipped(dir: &Path, written: &[PathBuf]) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
if written.iter().any(|w| w == relative || w.starts_with(relative)) {
continue;
}
if path.is_dir() {
let _ = fs::remove_dir_all(&path);
} else {
let _ = fs::remove_file(&path);
}
}
}
/// Read the version an installed skill was stamped with.
fn installed_version(dir: &Path) -> Option<String> {
let text = fs::read_to_string(dir.join("SKILL.md")).ok()?;
text.lines()
.find_map(|line| line.trim().strip_prefix(VERSION_MARKER))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty() && v != VERSION_PLACEHOLDER)
}
/// Health summary appended to root `--help`, so an agent can notice in one command
/// that an upgraded CLI has left the installed skill behind. Silent when nothing is
/// installed anywhere, to avoid nagging users who do not use agent tooling.
pub fn help_section() -> Option<String> {
let targets = resolve_targets(None).ok()?;
let current = version::cli_version();
let mut stale = Vec::new();
let mut installed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
if !dir.join("SKILL.md").exists() {
continue;
}
installed += 1;
let found = installed_version(&dir).unwrap_or_default();
if found != current {
let found = if found.is_empty() { "unknown".to_string() } else { found };
stale.push(format!("{} ({found})", target.label));
}
}
if installed == 0 {
return None;
}
if stale.is_empty() {
return Some(format!(
"Agent tooling:\n Yaak skill is installed and up to date ({current})"
));
}
Some(format!(
"Agent tooling:\n Yaak skill is out of date for {} — CLI is {current}\n Run `yaak agent install`, then restart your coding tool",
stale.join(", ")
))
}
/// Flatten the embedded skill directory into its files, recursing into subdirectories.
fn walk<'a>(dir: &'a Dir<'a>) -> Vec<&'a include_dir::File<'a>> {
let mut files: Vec<_> = dir.files().collect();
for child in dir.dirs() {
files.extend(walk(child));
}
files
}
/// `~/.agents/skills` is the cross-tool location and is always written. Tool-specific
/// directories are written only when that tool is already set up on this machine, so
/// installing never creates a config directory for a tool the user does not use.
fn resolve_targets(requested: Option<Vec<String>>) -> CommandResult<Vec<Target>> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let known: Vec<(&str, PathBuf, PathBuf)> = vec![
("agents", home.join(".agents"), home.join(".agents").join("skills")),
("claude-code", home.join(".claude"), home.join(".claude").join("skills")),
("cursor", home.join(".cursor"), home.join(".cursor").join("skills")),
("codex", home.join(".codex"), home.join(".codex").join("skills")),
("opencode", home.join(".opencode"), home.join(".opencode").join("skills")),
];
if let Some(requested) = requested {
let mut targets = Vec::new();
for name in requested {
let found =
known.iter().find(|(label, _, _)| *label == name.as_str()).ok_or_else(|| {
let names: Vec<_> = known.iter().map(|(l, _, _)| *l).collect();
format!("Unknown agent '{name}'. Known agents: {}", names.join(", "))
})?;
targets.push(Target { label: found.0, skills_dir: found.2.clone() });
}
return Ok(targets);
}
let targets: Vec<Target> = known
.into_iter()
.filter(|(label, marker, _)| *label == "agents" || marker.exists())
.map(|(label, _, skills_dir)| Target { label, skills_dir })
.collect();
Ok(targets)
}
@@ -5,7 +5,9 @@ use crate::utils::json::{
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
parse_required_json, require_id, validate_create_id,
};
use crate::utils::schema::append_agent_hints;
use crate::utils::workspace::resolve_workspace_id;
use schemars::schema_for;
use yaak_models::models::Folder;
use yaak_models::util::UpdateSource;
@@ -14,6 +16,7 @@ type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
let result = match args.command {
FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
FolderCommands::Schema { pretty } => schema(pretty),
FolderCommands::Show { folder_id } => show(ctx, &folder_id),
FolderCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
@@ -31,6 +34,18 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
}
}
fn schema(pretty: bool) -> CommandResult {
let mut schema = serde_json::to_value(schema_for!(Folder))
.map_err(|e| format!("Failed to serialize folder schema: {e}"))?;
append_agent_hints(&mut schema);
let output =
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
.map_err(|e| format!("Failed to format folder schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?;
let folders =
+3
View File
@@ -1,3 +1,4 @@
pub mod agent;
pub mod auth;
pub mod cookie_jar;
pub mod environment;
@@ -5,5 +6,7 @@ pub mod folder;
pub mod import_export;
pub mod plugin;
pub mod request;
pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
+77 -2
View File
@@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
Ok(())
}
/// `request create` only builds HTTP requests, but `request schema` will happily hand
/// out gRPC and WebSocket schemas. Without this, a gRPC payload deserializes into an
/// HttpRequest with its unknown fields dropped, quietly producing a broken request
/// (the gRPC method name lands in `method`, and `service` disappears).
fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult {
let Some(object) = payload.as_object() else {
return Ok(());
};
if let Some(model) = object.get("model").and_then(Value::as_str)
&& !model.is_empty()
&& model != "http_request"
{
return Err(format!(
"{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot work with {model} requests yet; use the Yaak app instead."
));
}
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(HttpRequest))
.ok()
.and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned())
.map(|properties| properties.keys().cloned().collect())
.unwrap_or_default();
if known.is_empty() {
return Ok(());
}
let unknown: Vec<&str> =
object.keys().filter(|key| !known.contains(*key)).map(String::as_str).collect();
if !unknown.is_empty() {
return Err(format!(
"{context} got fields that are not part of an HTTP request: {}. If this is a gRPC or WebSocket request, the CLI cannot work with it yet. Run `yaak request schema http` for the valid fields.",
unknown.join(", ")
));
}
Ok(())
}
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if !matches!(request_type, RequestSchemaType::Http) {
return;
@@ -119,7 +160,21 @@ fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) {
append_description(
url_schema,
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).",
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names that keep the leading `:` (for example `:id`, `:commentId`). A name without the `:` is sent as a query string parameter instead, leaving the placeholder in the path.",
);
}
if let Some(body_type_schema) = properties.get_mut("bodyType").and_then(Value::as_object_mut) {
append_description(
body_type_schema,
"Known values: `application/json`, `text/xml`, `application/x-www-form-urlencoded`, `multipart/form-data`, `graphql`, `binary`, `other`, or null for no body. This selects how `body` is encoded; it does NOT add a `Content-Type` header. Add that header yourself, matching the body type (`other` pairs with `text/plain` and `graphql` with `application/json`). Multipart is the exception: its header is generated at send time to carry the boundary.",
);
}
if let Some(body_schema) = properties.get_mut("body").and_then(Value::as_object_mut) {
append_description(
body_schema,
"Shape depends on `bodyType`. Text-ish types (`application/json`, `text/xml`, `other`) use `{\"text\": \"...\"}` where the value is a string, so JSON bodies are a JSON string containing JSON. Form types use `{\"form\": [{\"name\": \"a\", \"value\": \"1\", \"enabled\": true}]}`, and a multipart entry may use `file` (an absolute path) and `contentType` instead of `value`. `binary` uses `{\"filePath\": \"/abs/path\"}`. `graphql` uses `{\"query\": \"...\", \"variables\": \"{}\", \"operationName\": \"\"}` where `variables` is a string of JSON.",
);
}
}
@@ -353,6 +408,7 @@ fn create(
}
validate_create_id(&payload, "request")?;
reject_non_http_payload(&payload, "request create")?;
let mut request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
@@ -401,6 +457,7 @@ fn create(
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "request update")?;
let id = require_id(&patch, "request update")?;
reject_non_http_payload(&patch, "request update")?;
let existing = ctx
.db()
@@ -493,14 +550,24 @@ async fn send_http_request_by_id(
}
}
});
// Verbose mode has a second writer on stdout (the event task above), and the two
// race: the body could land in the middle of the headers, or run onto the same
// line as the status. So buffer the body while verbose and write it once the
// events are done. Without -v nothing else writes, so stream it straight through.
let body_handle = tokio::task::spawn_blocking(move || {
let mut buffered = Vec::new();
let mut stdout = std::io::stdout();
while let Some(chunk) = body_chunk_rx.blocking_recv() {
if verbose {
buffered.extend_from_slice(&chunk);
continue;
}
if stdout.write_all(&chunk).is_err() {
break;
}
let _ = stdout.flush();
}
buffered
});
let response_dir = ctx.data_dir().join("responses");
@@ -522,8 +589,16 @@ async fn send_http_request_by_id(
})
.await;
// Await the events first so every `*`, `>`, and `<` line is out before the body.
let _ = event_handle.await;
let _ = body_handle.await;
if let Ok(buffered) = body_handle.await
&& !buffered.is_empty()
{
let mut stdout = std::io::stdout();
let _ = stdout.write_all(&buffered);
let _ = stdout.flush();
}
result.map_err(|e| e.to_string())?;
Ok(())
}
@@ -0,0 +1,139 @@
use crate::cli::{ResponseArgs, ResponseCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::workspace::resolve_workspace_id;
use std::io::Write;
use yaak_models::models::HttpResponse;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
let result = match args.command {
ResponseCommands::List { id, limit } => list(ctx, id.as_deref(), limit),
ResponseCommands::Show { id } => show(ctx, &id),
ResponseCommands::Body { id } => body(ctx, &id),
ResponseCommands::Delete { id, yes } => delete(ctx, &id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
/// Accepts a response ID, or a request ID meaning "the latest response for that request".
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
if let Ok(response) = ctx.db().get_http_response(id) {
return Ok(response);
}
if ctx.db().get_http_request(id).is_ok() {
return ctx
.db()
.list_http_responses_for_request(id, Some(1))
.map_err(|e| format!("Failed to list responses: {e}"))?
.into_iter()
.next()
.ok_or_else(|| format!("Request {id} has no stored responses yet"));
}
Err(format!("Could not resolve ID '{id}' as a response or request"))
}
fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult {
// An explicit request ID scopes to that request; anything else lists the workspace.
let responses = match id {
Some(id) if ctx.db().get_http_request(id).is_ok() => ctx
.db()
.list_http_responses_for_request(id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?,
other => {
let workspace_id = resolve_workspace_id(ctx, other, "response list")?;
ctx.db()
.list_http_responses(&workspace_id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?
}
};
if responses.is_empty() {
println!("No responses found");
return Ok(());
}
for response in responses {
let status = match response.error {
// Errors can be a paragraph of nested debug output; `show` has the full text.
Some(error) => {
let first_line = error.lines().next().unwrap_or_default();
let summary = match first_line.char_indices().nth(80) {
Some((cut, _)) => format!("{}...", &first_line[..cut]),
None => first_line.to_string(),
};
format!("ERROR {summary}")
}
None => {
let reason = response.status_reason.unwrap_or_default();
format!("{} {}", response.status, reason).trim_end().to_string()
}
};
println!("{} - {} {} ({}ms)", response.id, status, response.url, response.elapsed);
}
Ok(())
}
fn show(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let output = serde_json::to_string_pretty(&response)
.map_err(|e| format!("Failed to serialize response: {e}"))?;
println!("{output}");
Ok(())
}
fn body(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let Some(body_path) = response.body_path else {
return Err(format!("Response {} has no stored body", response.id));
};
let bytes = std::fs::read(&body_path)
.map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?;
let mut stdout = std::io::stdout();
stdout.write_all(&bytes).map_err(|e| format!("Failed to write response body: {e}"))?;
stdout.flush().map_err(|e| format!("Failed to flush response body: {e}"))?;
Ok(())
}
fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
// Deleting is scoped to a whole request or workspace, since a single stored
// response is rarely the thing someone wants to remove.
if ctx.db().get_http_request(id).is_ok() {
if !yes && !confirm_delete("responses for request", id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for request {id}");
return Ok(());
}
let workspace_id = resolve_workspace_id(ctx, Some(id), "response delete")?;
if !yes && !confirm_delete("responses for workspace", &workspace_id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for workspace {workspace_id}");
Ok(())
}
@@ -0,0 +1,111 @@
use crate::cli::{TemplateFunctionArgs, TemplateFunctionCommands};
use crate::context::CliContext;
use yaak_plugins::events::{PluginContext, TemplateFunction};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run(ctx: &CliContext, args: TemplateFunctionArgs) -> i32 {
let result = match args.command {
TemplateFunctionCommands::List { filter } => list(ctx, filter.as_deref()).await,
TemplateFunctionCommands::Show { name, pretty } => show(ctx, &name, pretty).await,
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
/// Template functions come from plugins, so the only accurate list is the one the
/// installed plugins report right now.
async fn all(ctx: &CliContext) -> CommandResult<Vec<TemplateFunction>> {
let plugin_context = PluginContext::new_empty();
let summaries = ctx
.plugin_manager()
.get_template_function_summaries(&plugin_context)
.await
.map_err(|e| format!("Failed to list template functions: {e}"))?;
let mut functions: Vec<TemplateFunction> =
summaries.into_iter().flat_map(|summary| summary.functions).collect();
functions.sort_by(|a, b| a.name.cmp(&b.name));
Ok(functions)
}
async fn list(ctx: &CliContext, filter: Option<&str>) -> CommandResult {
let mut functions = all(ctx).await?;
if let Some(filter) = filter {
let needle = filter.to_lowercase();
functions.retain(|f| f.name.to_lowercase().contains(&needle));
}
if functions.is_empty() {
match filter {
Some(filter) => println!("No template functions matching '{filter}'"),
None => println!("No template functions found"),
}
return Ok(());
}
for function in functions {
let args = function.args.iter().filter_map(arg_name).collect::<Vec<_>>().join(", ");
match function.description {
Some(description) if !description.is_empty() => {
println!("{}({}) - {}", function.name, args, description)
}
_ => println!("{}({})", function.name, args),
}
}
Ok(())
}
async fn show(ctx: &CliContext, name: &str, pretty: bool) -> CommandResult {
let functions = all(ctx).await?;
let function = functions
.iter()
.find(|f| {
f.name == name
|| f.aliases.as_ref().is_some_and(|aliases| aliases.iter().any(|a| a == name))
})
.ok_or_else(|| {
let names = functions.iter().map(|f| f.name.as_str()).collect::<Vec<_>>();
format!("No template function named '{name}'. Available: {}", names.join(", "))
})?;
let output = if pretty {
serde_json::to_string_pretty(function)
} else {
serde_json::to_string(function)
}
.map_err(|e| format!("Failed to serialize template function: {e}"))?;
println!("{output}");
Ok(())
}
fn arg_name(arg: &yaak_plugins::events::TemplateFunctionArg) -> Option<String> {
use yaak_plugins::events::{FormInput, TemplateFunctionArg};
let TemplateFunctionArg::FormInput(input) = arg;
let base = match input {
FormInput::Text(v) => &v.base,
FormInput::Editor(v) => &v.base,
FormInput::Select(v) => &v.base,
FormInput::Checkbox(v) => &v.base,
FormInput::File(v) => &v.base,
FormInput::HttpRequest(v) => &v.base,
FormInput::KeyValue(v) => &v.base,
// Layout-only inputs have no value of their own
FormInput::Accordion(_)
| FormInput::HStack(_)
| FormInput::Banner(_)
| FormInput::Markdown(_) => return None,
};
if base.name.trim().is_empty() { None } else { Some(base.name.clone()) }
}
@@ -131,7 +131,7 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync)
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
Ok(())
+33 -3
View File
@@ -7,15 +7,31 @@ mod utils;
mod version;
mod version_check;
use clap::Parser;
use cli::{Cli, Commands, PluginCommands, RequestCommands};
use clap::{CommandFactory, FromArgMatches};
use cli::{AGENT_HINTS, Cli, Commands, PluginCommands, RequestCommands};
use context::{CliContext, CliExecutionContext};
use std::path::PathBuf;
use yaak_models::queries::any_request::AnyRequest;
/// Built at runtime so root `--help` can report whether the installed agent skill is
/// still in step with this CLI version.
fn help_footer() -> String {
match commands::agent::help_section() {
Some(section) => format!("{AGENT_HINTS}\n\n{section}"),
None => format!(
"{AGENT_HINTS}\n - Run `yaak agent install` to install the Yaak skill for AI coding agents"
),
}
}
#[tokio::main]
async fn main() {
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();
let matches = Cli::command().after_help(help_footer()).get_matches();
let Cli { data_dir, environment, cookie_jar, verbose, log, command } =
match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => error.exit(),
};
if let Some(log_level) = log {
match log_level {
@@ -36,6 +52,20 @@ async fn main() {
version_check::maybe_check_for_updates().await;
let exit_code = match command {
Commands::Agent(args) => commands::agent::run(args),
Commands::Response(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::response::run(&context, args);
context.shutdown().await;
exit_code
}
Commands::TemplateFunction(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await;
let exit_code = commands::template_function::run(&context, args).await;
context.shutdown().await;
exit_code
}
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Import(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
+3 -2
View File
@@ -1630,10 +1630,11 @@ async fn cmd_delete_all_http_responses<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.db().delete_all_http_responses_for_request(
app_handle.db().delete_all_http_responses_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
)?;
Ok(())
}
#[tauri::command]
+12
View File
@@ -1,5 +1,17 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// On Nvidia + Wayland, WebKit2GTK's DMA-BUF renderer triggers a protocol error (71) due to
// an explicit sync bug (https://bugs.webkit.org/show_bug.cgi?id=280210). Disabling explicit
// sync via the Nvidia driver avoids the crash without disabling hardware acceleration.
#[cfg(target_os = "linux")]
if std::env::var("__NV_DISABLE_EXPLICIT_SYNC").is_err()
&& std::env::var("WAYLAND_DISPLAY").is_ok()
&& std::path::Path::new("/sys/module/nvidia").exists()
{
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1") };
}
tauri_app_client_lib::run();
}
+64 -27
View File
@@ -14,7 +14,7 @@ use yaak_models::client_db::ClientDb;
use yaak_models::error::Result;
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_plugins::manager::PluginManager;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
@@ -57,6 +57,7 @@ fn drain_model_changes_batch<R: Runtime>(
}
let fetched_count = changes.len();
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
for change in changes {
cursor.created_at = change.created_at;
cursor.id = change.id;
@@ -66,8 +67,14 @@ fn drain_model_changes_batch<R: Runtime>(
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
continue;
}
if let Err(err) = app_handle.emit("model_write", change.payload) {
error!("Failed to emit model_write event: {err:?}");
batch.push(change.payload);
}
// Emit as a single batch so bulk writes (imports, sync, CLI) don't flood the
// frontend with per-model events.
if !batch.is_empty() {
if let Err(err) = app_handle.emit("model_writes", batch) {
error!("Failed to emit model_writes event: {err:?}");
}
}
@@ -162,33 +169,39 @@ pub(crate) fn models_upsert<R: Runtime>(
Ok(id)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
// blocking thread instead of stalling the main thread and all other IPC.
#[tauri::command]
pub(crate) fn models_delete<R: Runtime>(
pub(crate) async fn models_delete<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
tauri::async_runtime::spawn_blocking(move || {
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
})
})
.await
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
}
#[tauri::command]
@@ -364,6 +377,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
@@ -378,12 +405,22 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
// current sync-model UX snappy, while DB polling handles external writers (CLI).
let app_handle_local = app_handle.clone();
tauri::async_runtime::spawn(async move {
for payload in rx {
if !matches!(payload.update_source, UpdateSource::Window { .. }) {
while let Ok(payload) = rx.recv() {
let mut batch: Vec<ModelPayload> = Vec::new();
if matches!(payload.update_source, UpdateSource::Window { .. }) {
batch.push(payload);
}
// Coalesce any writes already queued into the same emit
while let Ok(next) = rx.try_recv() {
if matches!(next.update_source, UpdateSource::Window { .. }) {
batch.push(next);
}
}
if batch.is_empty() {
continue;
}
if let Err(err) = app_handle_local.emit("model_write", payload) {
error!("Failed to emit local model_write event: {err:?}");
if let Err(err) = app_handle_local.emit("model_writes", batch) {
error!("Failed to emit local model_writes event: {err:?}");
}
}
});
@@ -285,6 +285,19 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(not(target_os = "windows"))]
let node_bin_name = "yaaknode";
// In dev, spawn yaaknode from the source vendored dir, not the copy under
// target/. tauri-build rewrites the target copy in place when the source
// changes (e.g. a Node version bump between branches), and on macOS an
// in-place write permanently taints the inode's cached code signature —
// every later spawn of it dies with SIGKILL (Code Signature Invalid).
// vendor-node.cjs always recreates the source file on a fresh inode.
#[cfg(debug_assertions)]
let node_bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("vendored")
.join("node")
.join(node_bin_name);
#[cfg(not(debug_assertions))]
let node_bin_path = app_handle
.path()
.resolve(format!("vendored/node/{}", node_bin_name), BaseDirectory::Resource)
+3 -2
View File
@@ -3,7 +3,7 @@
//! This module provides the Tauri commands for sync functionality.
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use chrono::Utc;
use log::warn;
use serde::{Deserialize, Serialize};
@@ -55,7 +55,8 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
workspace_id: &str,
) -> Result<()> {
let db = app_handle.db();
let sync_state_ops = apply_sync_ops(&db, workspace_id, sync_dir, sync_ops)?;
let blobs = app_handle.blob_manager();
let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
Ok(())
}
@@ -160,6 +160,24 @@ impl<'a> DbContext<'a> {
Ok((m, created))
}
/// Bulk-delete all rows matching a column value with a single statement.
/// Returns the number of rows deleted.
pub fn delete_many<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Result<usize>
where
M: UpsertModelInfo,
{
let (sql, params) = Query::delete()
.from_table(M::table_name())
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
let count = self.conn.execute(sql.as_str(), &*params.as_params())?;
Ok(count)
}
/// Delete a model by its ID. Returns the number of rows deleted.
pub fn delete<M>(&self, m: &M) -> Result<usize>
where
@@ -21,5 +21,9 @@ impl UpdateSource {
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ModelChangeEvent {
Upsert { created: bool },
/// A delete for a workspace implies deletion of every model in that
/// workspace — children are bulk-deleted without their own change rows or
/// events, and consumers must prune the subtree themselves (the frontend
/// model store does this centrally).
Delete,
}
+87 -15
View File
@@ -17,28 +17,91 @@ export function initModelStore(store: JotaiStore) {
window.addEventListener("beforeunload", flushAllPendingPatches);
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (shouldIgnoreModel(payload)) return;
.listen<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
if (payload.change.type === "upsert") {
return {
...prev,
[payload.model.model]: {
...prev[payload.model.model],
[payload.model.id]: payload.model,
},
};
} else {
const modelData = { ...prev[payload.model.model] };
delete modelData[payload.model.id];
return { ...prev, [payload.model.model]: modelData };
// Apply the entire batch in one update, cloning each touched bucket only
// once. Bulk writes (imports, sync, CLI) can carry hundreds of models.
const next = { ...prev };
const clonedBuckets = new Set<AnyModel["model"]>();
let changed = false;
for (const payload of payloads) {
if (shouldIgnoreModel(payload)) continue;
if (isUnsafeObjectKey(payload.model.model)) continue;
if (isUnsafeObjectKey(payload.model.id)) continue;
if (payload.change.type === "upsert") {
const modelType = payload.model.model;
if (!clonedBuckets.has(modelType)) {
next[modelType] = { ...next[modelType] } as never;
clonedBuckets.add(modelType);
}
(next[modelType] as Record<string, AnyModel>)[payload.model.id] = payload.model;
changed = true;
} else {
changed = applyModelDelete(next, clonedBuckets, payload.model) || changed;
}
}
return changed ? next : prev;
});
})
.catch(console.error);
}
/**
* Model buckets are plain objects keyed by model id, so ids that collide with
* Object.prototype members ("__proto__" etc.) could pollute the prototype.
* Real model ids are backend-generated and never look like this.
*/
function isUnsafeObjectKey(key: string): boolean {
return key === "__proto__" || key === "constructor" || key === "prototype";
}
function deleteFromBucket(
next: ModelStoreData,
clonedBuckets: Set<AnyModel["model"]>,
modelType: AnyModel["model"],
id: string,
): boolean {
if (isUnsafeObjectKey(modelType)) return false;
if (isUnsafeObjectKey(id)) return false;
if (!Object.prototype.hasOwnProperty.call(next[modelType], id)) return false;
if (!clonedBuckets.has(modelType)) {
next[modelType] = { ...next[modelType] } as never;
clonedBuckets.add(modelType);
}
delete (next[modelType] as Record<string, AnyModel>)[id];
return true;
}
/**
* Apply a model delete to store data, mutating `next` in place (buckets are
* cloned once, tracked via `clonedBuckets`). A workspace delete implies its
* entire subtree: the backend bulk-deletes children and records/emits only the
* workspace event (see ModelChangeEvent), so prune them here.
*/
function applyModelDelete(
next: ModelStoreData,
clonedBuckets: Set<AnyModel["model"]>,
model: AnyModel,
): boolean {
let changed = deleteFromBucket(next, clonedBuckets, model.model, model.id);
if (model.model === "workspace") {
for (const modelType of Object.keys(next) as AnyModel["model"][]) {
const bucket = next[modelType] as Record<string, AnyModel>;
for (const [id, m] of Object.entries(bucket)) {
if ("workspaceId" in m && m.workspaceId === model.id) {
changed = deleteFromBucket(next, clonedBuckets, modelType, id) || changed;
}
}
}
}
return changed;
}
function mustStore(): JotaiStore {
if (_store == null) {
throw new Error("Model store was not initialized");
@@ -243,6 +306,15 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
throw new Error("Failed to delete null model");
}
await trackModelWrite(invoke<string>("models_delete", { model }));
// Apply the delete locally right away so callers can rely on the store once the
// promise resolves. The backend echo arrives async, so anything that reads the
// store immediately after awaiting (e.g. redirecting away from a deleted
// workspace) would otherwise race it. The echo re-applying later is a no-op.
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
const next = { ...prev };
return applyModelDelete(next, new Set(), model as AnyModel) ? next : prev;
});
}
export async function duplicateModel<
+9
View File
@@ -79,6 +79,15 @@ impl BlobContext {
Ok(chunks)
}
/// List all distinct body IDs in the blob database.
pub fn list_body_ids(&self) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare("SELECT DISTINCT body_id FROM body_chunks")?;
let ids = stmt
.query_map([], |row| row.get(0))?
.collect::<std::result::Result<Vec<String>, _>>()?;
Ok(ids)
}
/// Delete all chunks for a body.
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
+15
View File
@@ -65,6 +65,21 @@ impl<'a> ClientDb<'a> {
Ok(self.ctx.find_many(col, value, limit)?)
}
/// Bulk-delete all rows matching a column value WITHOUT recording model
/// changes or emitting events. Only use for cascades whose deletion is
/// implied by a recorded parent delete (e.g. workspace children — see
/// [`ModelChangeEvent::Delete`]).
pub(crate) fn delete_many_untracked<M>(
&self,
col: impl IntoColumnRef,
value: impl Into<SimpleExpr>,
) -> Result<usize>
where
M: UpsertModelInfo,
{
Ok(self.ctx.delete_many::<M>(col, value)?)
}
// --- Write operations (with event recording) ---
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
+1 -1
View File
@@ -949,7 +949,7 @@ pub struct ParentHeaders {
pub headers: Vec<HttpRequestHeader>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "folders")]
@@ -30,29 +30,83 @@ impl<'a> ClientDb<'a> {
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
}
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<()> {
) -> Result<usize> {
let responses = self.list_http_responses_for_request(request_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(())
Ok(count)
}
/// Delete response body data (blob chunks and body files) whose owning HTTP
/// response row no longer exists. Cascaded deletes (request, folder,
/// workspace) historically never cleaned the blob DB or the responses
/// directory, so orphans accumulate; this runs in the background at startup.
///
/// Safe against in-flight sends: the response row is created before its
/// body file or chunks are written.
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_bodies(
&self,
blobs: &BlobManager,
responses_dir: &std::path::Path,
) -> Result<usize> {
let mut deleted = 0;
// Blob chunks are keyed "{response_id}.request"
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
// Body files are stored as {responses_dir}/{response_id}
if let Ok(entries) = fs::read_dir(responses_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
{
continue;
}
if fs::remove_file(&path).is_ok() {
deleted += 1;
}
}
}
Ok(deleted)
}
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_workspace(
&self,
workspace_id: &str,
source: &UpdateSource,
) -> Result<()> {
) -> Result<usize> {
let responses =
self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, workspace_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(())
Ok(count)
}
pub fn delete_http_response(
@@ -114,3 +168,68 @@ impl<'a> ClientDb<'a> {
if response.id.is_empty() { Ok(response.clone()) } else { self.upsert(response, source) }
}
}
#[cfg(test)]
mod tests {
use crate::blob_manager::BodyChunk;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
use crate::util::UpdateSource;
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(&Workspace { name: "GC Test".to_string(), ..Default::default() }, source)
.expect("Failed to upsert workspace");
let request = db
.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)
.expect("Failed to upsert request");
let live = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
&blob_manager,
)
.expect("Failed to upsert response");
let live_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new(&live_body_id, 0, b"live".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
let dir = std::env::temp_dir().join(format!("yaak-blob-gc-test-{}", live.id));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(&live.id), b"live").unwrap();
std::fs::write(dir.join("rs_gone"), b"dead").unwrap();
let deleted = db
.delete_orphaned_response_bodies(&blob_manager, &dir)
.expect("Failed to GC response bodies");
assert_eq!(deleted, 2);
// Live data survives, orphans are gone
let blob_ctx = blob_manager.connect();
assert!(blob_ctx.body_exists(&live_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
assert!(dir.join(&live.id).exists());
assert!(!dir.join("rs_gone").exists());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -103,7 +103,7 @@ mod tests {
#[test]
fn records_model_changes_for_upsert_and_delete() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspace = db
@@ -128,7 +128,7 @@ mod tests {
));
assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync));
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync)
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
.expect("Failed to delete workspace");
let all_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes");
@@ -178,7 +178,7 @@ mod tests {
#[test]
fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspace = db
@@ -192,7 +192,7 @@ mod tests {
&UpdateSource::Sync,
)
.expect("Failed to upsert workspace");
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync)
db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync, &blob_manager)
.expect("Failed to delete workspace");
let all = db.list_model_changes_after(0, 10).expect("Failed to list changes");
+92 -23
View File
@@ -1,10 +1,17 @@
use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
AnyModel, EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
ResolvedHttpRequestSettings, ResolvedSetting, WebsocketRequestIden, Workspace, WorkspaceIden,
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden,
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
};
use crate::util::UpdateSource;
use log::warn;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -32,37 +39,99 @@ impl<'a> ClientDb<'a> {
Ok(workspaces)
}
/// Delete a workspace and everything in it.
///
/// Children are bulk-deleted with one statement per table and are NOT
/// individually recorded in model_changes or emitted as events — the single
/// workspace delete event implies the subtree (see [`ModelChangeEvent::Delete`]).
/// This keeps huge workspaces (thousands of requests) fast and avoids
/// flooding event consumers.
pub fn delete_workspace(
&self,
workspace: &Workspace,
source: &UpdateSource,
blobs: &BlobManager,
) -> Result<Workspace> {
for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_http_request(&m, source)?;
let wid = workspace.id.as_str();
// Collect response cleanup targets before their rows disappear. The actual
// cleanup runs at the end: response bodies live on disk and in the blob DB,
// which don't participate in this transaction, so removing them must wait
// until every statement that could fail (and roll back the rows) is done.
let responses = self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, wid, None)?;
// Sync and the CLI call this on a plain connection where each statement
// would otherwise commit on its own, leaving a partially-deleted workspace
// if one fails. A savepoint makes the cascade atomic there, and nests
// harmlessly inside the interactive path's transaction.
let conn = self.conn().resolve();
conn.execute_batch("SAVEPOINT delete_workspace")?;
let result: Result<Workspace> = (|| {
self.delete_many_untracked::<HttpResponseEvent>(
HttpResponseEventIden::WorkspaceId,
wid,
)?;
self.delete_many_untracked::<HttpResponse>(HttpResponseIden::WorkspaceId, wid)?;
self.delete_many_untracked::<HttpRequest>(HttpRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcEvent>(GrpcEventIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcConnection>(GrpcConnectionIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GrpcRequest>(GrpcRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WebsocketEvent>(WebsocketEventIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WebsocketConnection>(
WebsocketConnectionIden::WorkspaceId,
wid,
)?;
self.delete_many_untracked::<WebsocketRequest>(WebsocketRequestIden::WorkspaceId, wid)?;
self.delete_many_untracked::<GraphQlIntrospection>(
GraphQlIntrospectionIden::WorkspaceId,
wid,
)?;
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
self.delete(workspace, source)
})();
let deleted = match result {
Ok(deleted) => {
conn.execute_batch("RELEASE delete_workspace")?;
deleted
}
Err(e) => {
let _ = conn
.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
return Err(e);
}
};
// Best-effort cleanup of response bodies (disk files and blob chunks).
// Failures only orphan unreferenced data, and are logged.
let blob_ctx = blobs.connect();
for m in responses {
if let Some(p) = m.body_path {
if let Err(e) = std::fs::remove_file(&p) {
warn!("Failed to delete response body file {p:?}: {e}");
}
}
if let Err(e) = blob_ctx.delete_chunks_like(&format!("{}.%", m.id)) {
warn!("Failed to delete blobs for response {}: {e}", m.id);
}
}
for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? {
self.delete_websocket_request(&m, source)?;
}
for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? {
self.delete_folder(&m, source)?;
}
for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? {
self.delete_environment(&m, source)?;
}
self.delete(workspace, source)
Ok(deleted)
}
pub fn delete_workspace_by_id(&self, id: &str, source: &UpdateSource) -> Result<Workspace> {
pub fn delete_workspace_by_id(
&self,
id: &str,
source: &UpdateSource,
blobs: &BlobManager,
) -> Result<Workspace> {
let workspace = self.get_workspace(id)?;
self.delete_workspace(&workspace, source)
self.delete_workspace(&workspace, source, blobs)
}
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
+5 -3
View File
@@ -1,4 +1,5 @@
use crate::error::Result;
use yaak_models::blob_manager::BlobManager;
use crate::models::SyncModel;
use chrono::Utc;
use log::{info, warn};
@@ -339,6 +340,7 @@ fn workspace_models(db: &ClientDb, version: &str, workspace_id: &str) -> Result<
/// Returns a list of SyncStateOps that should be applied afterward.
pub fn apply_sync_ops(
db: &ClientDb,
blobs: &BlobManager,
workspace_id: &str,
sync_dir: &Path,
sync_ops: Vec<SyncOp>,
@@ -435,7 +437,7 @@ pub fn apply_sync_ops(
}
}
SyncOp::DbDelete { model, state } => {
delete_model(db, &model)?;
delete_model(db, blobs, &model)?;
SyncStateOp::Delete { state: state.to_owned() }
}
SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp,
@@ -547,10 +549,10 @@ fn derive_model_filename(m: &SyncModel) -> PathBuf {
Path::new(&rel).to_path_buf()
}
fn delete_model(db: &ClientDb, model: &SyncModel) -> Result<()> {
fn delete_model(db: &ClientDb, blobs: &BlobManager, model: &SyncModel) -> Result<()> {
match model {
SyncModel::Workspace(m) => {
db.delete_workspace(&m, &UpdateSource::Sync)?;
db.delete_workspace(&m, &UpdateSource::Sync, blobs)?;
}
SyncModel::Environment(m) => {
db.delete_environment(&m, &UpdateSource::Sync)?;