mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-14 23:52:10 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0af512a0c | ||
|
|
e032c4c8d6 | ||
|
|
c3fc3fb68a |
@@ -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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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])?;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user