Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/desktop/src-tauri/src/state/workspace_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ impl AppState {
.runtime
.clone()
.ok_or_else(|| desktop_state_unavailable("Configure a Runner first"))?;
tokio::task::spawn_blocking(move || {
crate::webcodex::settings::save_job_concurrency(&runtime, request)
let edit = tokio::task::spawn_blocking({
let runtime = runtime.clone();
move || crate::webcodex::settings::stage_job_concurrency(&runtime, request)
})
.await
.map_err(|_| desktop_state_unavailable("Settings worker stopped"))??;
apply_staged_settings(&runtime, &edit).await?;
core.get_state().await
}
.await;
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src-tauri/src/state/workspace_settings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,62 @@ async fn job_concurrency_save_rejects_unowned_runner_without_changing_the_file()
before
);
}

#[tokio::test]
async fn job_capacity_uses_the_shared_staged_transaction_for_all_outcomes() {
for (responses, restored, applied) in [
(vec![Ok(check(4)), Ok(reload(5))], false, true),
(vec![Err(desktop_state_unavailable("offline"))], true, false),
(
vec![
Ok(check(4)),
Ok(
serde_json::json!({"success":false,"output":{"execution_state":"not_started","error_code":"config_generation_conflict"}}),
),
Ok(check(5)),
],
false,
false,
),
(
vec![
Ok(check(4)),
Err(desktop_state_unavailable("response lost")),
Ok(check(5)),
],
false,
false,
),
] {
let (_root, runtime, previous_edit, _original) = fixture();
let baseline = std::fs::read_to_string(runtime.runner_config.as_ref().unwrap()).unwrap();
assert!(previous_edit.candidate_unchanged().unwrap());
let edit = settings::stage_job_concurrency(
&runtime,
settings::JobConcurrencyUpdate {
target: settings::target(&runtime).unwrap(),
expected: None,
limit: 6,
},
)
.unwrap();
let mut responses = VecDeque::from(responses);
let mut reloads = 0;
let result = apply_with_control("local", &edit, |tool, params| {
if tool == "reload_runner_config" {
reloads += 1;
assert_eq!(params["expected_generation"], 4);
}
ready(responses.pop_front().unwrap())
})
.await;
assert_eq!(result.is_ok(), applied);
assert!(reloads <= 1);
assert!(responses.is_empty());
let current = std::fs::read_to_string(runtime.runner_config.as_ref().unwrap()).unwrap();
assert_eq!(current == baseline, restored);
if !restored {
assert!(edit.candidate_unchanged().unwrap());
}
}
}
14 changes: 10 additions & 4 deletions apps/desktop/src-tauri/src/webcodex/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,12 @@ fn configured_job_concurrency(doc: &DocumentMut) -> DesktopResult<Option<usize>>
.transpose()
}

/// Startup-only setting: saving does not reload or restart the active Runner.
/// Stage capacity through the same checked reload transaction as other hot settings.
/// Preserve unrelated TOML and fence both the displayed value and full-file write.
pub fn save_job_concurrency(
pub fn stage_job_concurrency(
runtime: &StoredRuntime,
request: JobConcurrencyUpdate,
) -> DesktopResult<()> {
) -> DesktopResult<PendingSettingsEdit> {
verify_target(runtime, &request.target)?;
if !valid_job_concurrency(request.limit) {
return Err(job_concurrency_error());
Expand All @@ -362,7 +362,13 @@ pub fn save_job_concurrency(
return Err(job_concurrency_error());
}
doc["max_concurrent_jobs"] = toml_edit::value(request.limit as i64);
persist(path, &original, &doc)
let candidate = doc.to_string();
persist_text(path, &original, &candidate)?;
Ok(PendingSettingsEdit {
path: path.clone(),
original,
candidate,
})
}

pub fn stage_allowed_roots_update(
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src-tauri/src/webcodex/settings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ fn job_concurrency_save_preserves_config_and_requires_exact_saved_value() {
expected,
limit,
};
save_job_concurrency(&f.runtime, request(None, 12)).unwrap();
stage_job_concurrency(&f.runtime, request(None, 12)).unwrap();
assert_eq!(
inspect(&f.runtime, true).unwrap().max_concurrent_jobs,
Some(12)
Expand All @@ -372,20 +372,20 @@ fn job_concurrency_save_preserves_config_and_requires_exact_saved_value() {
] {
assert!(text.contains(preserved));
}
assert!(save_job_concurrency(&f.runtime, request(None, 8)).is_err());
assert!(stage_job_concurrency(&f.runtime, request(None, 8)).is_err());
for limit in [0, 65] {
assert!(save_job_concurrency(&f.runtime, request(Some(12), limit)).is_err());
assert!(stage_job_concurrency(&f.runtime, request(Some(12), limit)).is_err());
}
let mut wrong = request(Some(12), 8);
wrong.target.client_id = "other".into();
assert!(save_job_concurrency(&f.runtime, wrong).is_err());
assert!(stage_job_concurrency(&f.runtime, wrong).is_err());
assert_eq!(
read(f.runtime.runner_config.as_ref().unwrap()).unwrap(),
text
);
for limit in [1, 64] {
let expected = inspect(&f.runtime, true).unwrap().max_concurrent_jobs;
save_job_concurrency(&f.runtime, request(expected, limit)).unwrap();
stage_job_concurrency(&f.runtime, request(expected, limit)).unwrap();
}
}

Expand Down
40 changes: 31 additions & 9 deletions apps/desktop/src/features/settings/RunnerCapacityPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,34 @@ describe("local Runner concurrency settings", () => {
expect(input).toHaveValue(32); expect(screen.getByText("4 running · 2 queued · limit 4")).toBeInTheDocument();
expect(screen.getByText("Saved limit: — · Default: 32")).toBeInTheDocument();
expect(screen.queryByText("Saved limit is in effect.")).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "12" } }); fireEvent.click(screen.getByRole("button", { name: "Save for next restart" }));
fireEvent.change(input, { target: { value: "12" } }); fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
await waitFor(() => expect(api.saveRunnerJobConcurrency).toHaveBeenCalledExactlyOnceWith(target, null, 12));
expect(api.restartOwnedRunner).not.toHaveBeenCalled();
expect(await screen.findByText("Restart required to apply the saved limit.")).toBeInTheDocument();
expect(await screen.findByText("Saved limit is not yet reported as active.")).toBeInTheDocument();
expect(screen.getByText("4 running · 2 queued · limit 4")).toBeInTheDocument();
});
it("shows applied capacity from the server even while running exceeds a shrunken limit", async () => {
settings.max_concurrent_jobs = 6;
runner = { ...runner, jobs_running: 6, jobs_queued: 2, job_concurrency_limit: 6 };
api.saveRunnerJobConcurrency.mockImplementationOnce(async () => {
settings.max_concurrent_jobs = 2;
runner = { ...runner, job_concurrency_limit: 2 };
return state;
});
render(view());
fireEvent.change(await screen.findByRole("spinbutton"), { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
expect(await screen.findByText("6 running · 2 queued · limit 2")).toBeInTheDocument();
expect(screen.getByText("Saved limit is in effect.")).toBeInTheDocument();
expect(api.restartOwnedRunner).not.toHaveBeenCalled();
});
it("allows an explicit apply of a saved limit awaiting activation", async () => {
settings.max_concurrent_jobs = 6;
render(view());
fireEvent.click(await screen.findByRole("button", { name: "Save and apply" }));
await waitFor(() => expect(api.saveRunnerJobConcurrency).toHaveBeenCalledExactlyOnceWith(target, 6, 6));
expect(api.restartOwnedRunner).not.toHaveBeenCalled();
});
it("requires an interruption warning and supports Cancel", async () => {
settings.max_concurrent_jobs = 12; render(view());
fireEvent.click(await screen.findByRole("button", { name: "Restart Runner…" }));
Expand All @@ -44,13 +66,13 @@ describe("local Runner concurrency settings", () => {
fireEvent.click(screen.getByRole("button", { name: "Restart Runner…" })); dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Restart Runner now" }));
await waitFor(() => expect(api.restartOwnedRunner).toHaveBeenCalledExactlyOnceWith(target));
expect(await screen.findByText("Restart required to apply the saved limit.")).toBeInTheDocument();
expect(await screen.findByText("Saved limit is not yet reported as active.")).toBeInTheDocument();
});
it("rejects invalid input and does not replay a failed save", async () => {
render(view()); const input = await screen.findByRole("spinbutton");
for (const value of ["", "0", "65", "1.5"]) { fireEvent.change(input, { target: { value } }); expect(screen.getByRole("button", { name: "Save for next restart" })).toBeDisabled(); }
for (const value of ["", "0", "65", "1.5"]) { fireEvent.change(input, { target: { value } }); expect(screen.getByRole("button", { name: "Save and apply" })).toBeDisabled(); }
api.saveRunnerJobConcurrency.mockRejectedValue({ code: "runner_job_concurrency_invalid", message: "Saved value changed", next_action: "Reload settings" });
fireEvent.change(input, { target: { value: "12" } }); fireEvent.click(screen.getByRole("button", { name: "Save for next restart" }));
fireEvent.change(input, { target: { value: "12" } }); fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Saved value changed");
expect(api.saveRunnerJobConcurrency).toHaveBeenCalledTimes(1); expect(api.restartOwnedRunner).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -93,7 +115,7 @@ describe("local Runner concurrency settings", () => {
api.saveRunnerJobConcurrency.mockReturnValue(new Promise(done => { resolve = done; }));
render(view()); const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "12" } });
const save = screen.getByRole("button", { name: "Save for next restart" });
const save = screen.getByRole("button", { name: "Save and apply" });
fireEvent.click(save); fireEvent.click(save);
expect(api.saveRunnerJobConcurrency).toHaveBeenCalledTimes(1);
await act(async () => resolve(state));
Expand All @@ -105,22 +127,22 @@ describe("local Runner concurrency settings", () => {
settings.max_concurrent_jobs = 8;
fireEvent(document, new Event("visibilitychange"));
await screen.findByText("Saved limit: 8 · Default: 32");
fireEvent.click(screen.getByRole("button", { name: "Save for next restart" }));
fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Saved value changed");
expect(api.saveRunnerJobConcurrency).toHaveBeenCalledExactlyOnceWith(target, null, 12);
});
it("recovers a conflicting save through an explicit reload and a new edit", async () => {
render(view()); const input = await screen.findByRole("spinbutton");
fireEvent.change(input, { target: { value: "12" } });
settings.max_concurrent_jobs = 8;
fireEvent.click(screen.getByRole("button", { name: "Save for next restart" }));
fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Saved value changed");
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(input).toHaveValue(8));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "12" } });
fireEvent.click(screen.getByRole("button", { name: "Save for next restart" }));
fireEvent.click(screen.getByRole("button", { name: "Save and apply" }));
await waitFor(() => expect(api.saveRunnerJobConcurrency).toHaveBeenLastCalledWith(target, 8, 12));
expect(api.saveRunnerJobConcurrency).toHaveBeenCalledTimes(2);
});
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/features/settings/RunnerCapacityPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function RunnerCapacitySettings({ state, onState, active }: Props) {
const canEdit = state.topology?.runner?.kind === "local" && Boolean(settings?.can_restart) && saved !== undefined;
const disabled = busy || operationBusy || !canEdit;
const changed = limit !== null && limit !== savedLimit;
const canApply = changed || (saved != null && limit === saved && capacity.state === "available" && capacity.limit !== saved);
const run = async (work: () => Promise<void>) => {
if (action.current || disabled) return;
action.current = true; setBusy(true); setError(null);
Expand All @@ -99,7 +100,7 @@ function RunnerCapacitySettings({ state, onState, active }: Props) {
}
};
const save = () => run(async () => {
if (!settings || saved === undefined || limit === null || !changed) return;
if (!settings || saved === undefined || limit === null || !canApply) return;
const next = await desktopApi.saveRunnerJobConcurrency(settings.target, editBase.current === undefined ? saved : editBase.current, limit);
editBase.current = undefined;
if (alive.current) onState(next);
Expand Down Expand Up @@ -138,17 +139,17 @@ function RunnerCapacitySettings({ state, onState, active }: Props) {
</section>}
{loading ? <p role="status">{s("Loading…")}</p> : <>
<p>{s("Saved limit: {saved} · Default: {default}").replace("{saved}", String(savedDisplay)).replace("{default}", "32")}</p>
{saved != null && savedLimit !== null && <p role="status">{s(capacity.state !== "available" ? "Saved for the next Runner start; the effective limit is unknown." : capacity.limit !== savedLimit ? "Restart required to apply the saved limit." : "Saved limit is in effect.")}</p>}
{saved != null && savedLimit !== null && <p role="status">{s(capacity.state !== "available" ? "The effective limit is unknown. Reconnect and refresh before applying." : capacity.limit !== savedLimit ? "Saved limit is not yet reported as active." : "Saved limit is in effect.")}</p>}
{saved !== undefined && <div className="field-group"><label htmlFor="runner-job-concurrency">{s("Maximum concurrent jobs")}</label>
<input id="runner-job-concurrency" type="number" min={1} max={64} step={1} value={draft ?? ""} onChange={event => {
editBase.current = event.target.value === String(savedLimit) ? undefined : editBase.current === undefined ? saved : editBase.current;
setDraft(event.target.value);
}} disabled={disabled} aria-describedby="runner-job-concurrency-help" />
<p id="runner-job-concurrency-help" className="field-help">{s("Choose a whole number from 1 to 64. Saving does not restart the Runner.")}</p>
<p id="runner-job-concurrency-help" className="field-help">{s("Choose a whole number from 1 to 64. Apply without restarting; running jobs continue.")}</p>
</div>}
{!canEdit && !settingsUnavailable && <p className="field-help">{s("Read-only. Change and restart this Runner through its actual process owner.")}</p>}
{!canEdit && !settingsUnavailable && <p className="field-help">{s("Read-only. Change this Runner through its actual process owner.")}</p>}
<div className="connection-actions">
{saved !== undefined && <button type="button" className="secondary-button" disabled={disabled || !changed} onClick={() => void save()}>{s("Save for next restart")}</button>}
{saved !== undefined && <button type="button" className="secondary-button" disabled={disabled || !canApply} onClick={() => void save()}>{s("Save and apply")}</button>}
{canEdit && <button type="button" className="secondary-button" disabled={disabled || changed || limit === null} onClick={() => setConfirm(settings)}>{s("Restart Runner…")}</button>}
<button type="button" className="text-button" disabled={busy || operationBusy} onClick={() => void refresh(true, true)}>{s("Refresh")}</button>
</div>
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/i18n/runtime-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ const zh: Record<string, string> = {

"Saved limit: {saved} · Default: {default}": "已保存的上限:{saved} · 默认值:{default}",

"Saved for the next Runner start; the effective limit is unknown.": "已保存,将在 Runner 下次启动时使用;当前生效上限未知。",
"Restart required to apply the saved limit.": "需要重启 Runner,已保存的上限才会生效。",
"The effective limit is unknown. Reconnect and refresh before applying.": "当前生效上限未知。请重新连接并刷新后再应用。",
"Saved limit is not yet reported as active.": "服务端尚未报告已保存的上限生效。",
"Saved limit is in effect.": "已保存的上限正在生效。",
"Maximum concurrent jobs": "最大并发任务数",
"Choose a whole number from 1 to 64. Saving does not restart the Runner.": "请输入 1–64 的整数。保存不会重启 Runner。",
"Read-only. Change and restart this Runner through its actual process owner.": "此处只读。请通过实际管理此 Runner 的程序修改配置并重启。",
"Save for next restart": "保存,下次重启时生效",
"Choose a whole number from 1 to 64. Apply without restarting; running jobs continue.": "请输入 1–64 的整数。热应用无需重启,正在运行的任务会继续执行。",
"Read-only. Change this Runner through its actual process owner.": "此处只读。请通过实际管理此 Runner 的程序修改配置。",
"Save and apply": "保存并热应用",
"Restart Runner…": "重启 Runner…",
"Restart Runner?": "确定重启 Runner?",
"Restart Runner now": "立即重启 Runner",
Expand Down
Loading
Loading