feat(locks): show a lock indicator on the lane card (D)

This commit is contained in:
2026-08-04 11:32:34 +07:00
parent 4a8725f136
commit 1f3a5ff51d
6 changed files with 186 additions and 91 deletions
+47 -1
View File
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import type { Lane, LaneGitFacts, LaneRuntime } from "../../lib/types"; import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose: /** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is three git subprocesses server-side, and a branch name does not * each call is three git subprocesses server-side, and a branch name does not
@@ -28,6 +28,10 @@ const GIT_REFRESH_MS = 30_000;
* the lane poll because each call opens a socket per declared port. */ * the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000; const RUNTIME_REFRESH_MS = 10_000;
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
* as the git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;
/** /**
* The lane's own working copy, fetched per card rather than folded into the * The lane's own working copy, fetched per card rather than folded into the
* polled lane list. Absent facts are not an error state: a lane may point at a * polled lane list. Absent facts are not an error state: a lane may point at a
@@ -95,6 +99,37 @@ function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
return runtime; return runtime;
} }
/**
* Locks held by THIS lane, polled the same way runtime/git facts are.
*/
function useLaneLocks(laneSlot: number | null): NamedLock[] {
const [locks, setLocks] = useState<NamedLock[]>([]);
useEffect(() => {
if (!laneSlot) return;
let alive = true;
const holder = `lane${laneSlot}`;
const read = () => {
api.locks
.list()
.then((data) => {
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
})
.catch(() => {
/* fails silently, same contract as the git/runtime pollers */
});
};
read();
const timer = setInterval(read, LOCKS_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneSlot]);
return locks;
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = { const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success", active: "bg-status-success",
idle: "bg-surface-4", idle: "bg-surface-4",
@@ -125,6 +160,7 @@ export default function LaneCard({
const [bootLine, setBootLine] = useState<string | null>(null); const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id); const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump); const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
/** /**
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`: * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
@@ -302,6 +338,16 @@ export default function LaneCard({
</div> </div>
)} )}
{locks.length > 0 && (
<div
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
data-testid={`lane-locks-${lane.id}`}
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
>
🔒 {t("locks.held", { count: locks.length })}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary"> <dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && ( {git?.available && (
<div data-testid="lane-git" className="space-y-1"> <div data-testid="lane-git" className="space-y-1">
@@ -24,6 +24,9 @@ vi.mock("../../../lib/api", () => ({
down: vi.fn(), down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
}, },
locks: {
list: vi.fn(),
},
}, },
})); }));
@@ -35,6 +38,8 @@ beforeEach(() => {
vi.mocked(api.lanes.runtime).mockReset(); vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
}); });
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
function makeLane(overrides: Partial<Lane> = {}): Lane { function makeLane(overrides: Partial<Lane> = {}): Lane {
return { return {
@@ -335,3 +340,24 @@ describe("LaneCard — the lane's own application stack", () => {
expect(screen.getByText(/health check failed/)).toBeInTheDocument(); expect(screen.getByText(/health check failed/)).toBeInTheDocument();
}); });
}); });
describe("LaneCard — named locks", () => {
it("shows a lock badge when this lane holds a named lock", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
expect(screen.getByText(/lock/i)).toBeInTheDocument();
});
it("shows no lock badge when locks belong to a different lane", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
});
});
+11 -9
View File
@@ -11,7 +11,7 @@
"add": "Add lane", "add": "Add lane",
"addLane": "Create a lane from a working directory", "addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from", "addLaneBaseLabel": "Branch to fork from",
"addLaneNoBranches": "This repo has no commits yet the worktree will start empty.", "addLaneNoBranches": "This repo has no commits yet \u2014 the worktree will start empty.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.", "addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoLabel": "Source repository", "addLaneRepoLabel": "Source repository",
@@ -40,9 +40,9 @@
"destructive.count.unpushed": "Unpushed commits", "destructive.count.unpushed": "Unpushed commits",
"destructive.count.untracked": "Untracked files", "destructive.count.untracked": "Untracked files",
"destructive.force": "I understand these unpushed commits will be discarded.", "destructive.force": "I understand these unpushed commits will be discarded.",
"destructive.loading": "Loading current facts", "destructive.loading": "Loading current facts\u2026",
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.", "destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped the directory and its files are left untouched.", "destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped \u2014 the directory and its files are left untouched.",
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.", "destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.", "destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.",
"destructive.purge.confirm": "Purge history", "destructive.purge.confirm": "Purge history",
@@ -57,22 +57,24 @@
"destructive.reset.title": "Reset managed worktree?", "destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.", "destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"emptyState": "No lanes yet. Create one from a working directory:", "emptyState": "No lanes yet. Create one from a working directory:",
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", "git.uncommitted": "{{dirty}} modified \u00b7 {{untracked}} untracked",
"kind.adopted": "adopted", "kind.adopted": "adopted",
"kind.managed": "managed", "kind.managed": "managed",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Lane {{id}} \u00b7 {{title}} \u00b7 {{pipeline}}",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"moreActions": "More actions", "moreActions": "More actions",
"preflightError": "Could not load the current lane facts.", "preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}", "preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"runtime.boot": " up", "runtime.boot": "\u25b6 up",
"runtime.busy.down": "stopping", "runtime.busy.down": "stopping\u2026",
"runtime.busy.up": "booting", "runtime.busy.up": "booting\u2026",
"runtime.down": "down", "runtime.down": "down",
"runtime.healthy": "healthy", "runtime.healthy": "healthy",
"runtime.partial": "partial", "runtime.partial": "partial",
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": " down", "runtime.stop": "\u25a0 down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)", "runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"stageUndeclared": "not declared", "stageUndeclared": "not declared",
"status.failed": "failed", "status.failed": "failed",
+83 -81
View File
@@ -1,85 +1,87 @@
{ {
"action.clear": "dọn trạng thái", "action.clear": "d\u1ecdn tr\u1ea1ng th\u00e1i",
"action.forget": "xóa làn", "action.forget": "x\u00f3a l\u00e0n",
"action.purge": "xóa lịch sử", "action.purge": "x\u00f3a l\u1ecbch s\u1eed",
"action.remove": "xóa làn + worktree", "action.remove": "x\u00f3a l\u00e0n + worktree",
"action.reset": "đặt lại worktree", "action.reset": "\u0111\u1eb7t l\u1ea1i worktree",
"action.start": "bắt đầu", "action.start": "b\u1eaft \u0111\u1ea7u",
"action.stop": "dng", "action.stop": "d\u1eebng",
"actionError": "Thao tác làn đường thất bại: {{message}}", "actionError": "Thao t\u00e1c l\u00e0n \u0111\u01b0\u1eddng th\u1ea5t b\u1ea1i: {{message}}",
"actionErrorUnknown": "Lỗi không xác định", "actionErrorUnknown": "L\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh",
"add": "Thêm lane", "add": "Th\u00eam lane",
"addLane": "To lane từ một thư mục làm việc", "addLane": "T\u1ea1o lane t\u1eeb m\u1ed9t th\u01b0 m\u1ee5c l\u00e0m vi\u1ec7c",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới", "addLaneBaseLabel": "Nh\u00e1nh \u0111\u1ec3 t\u1ea1o nh\u00e1nh m\u1edbi",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", "addLaneNoBranches": "Repo n\u00e0y ch\u01b0a c\u00f3 commit n\u00e0o \u2014 worktree s\u1ebd b\u1eaft \u0111\u1ea7u tr\u1ed1ng.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", "addLaneNotARepo": "Ch\u01b0a ph\u1ea3i repo git (ho\u1eb7c kh\u00f4ng c\u00f3 quy\u1ec1n \u0111\u1ecdc).",
"addLaneRepoHint": "Mt repo git có sẵn. Dashboard tự tạo worktree mi cho lane, không phải thư mục bạn chọn.", "addLaneRepoHint": "M\u1ed9t repo git c\u00f3 s\u1eb5n. Dashboard t\u1ef1 t\u1ea1o worktree m\u1edbi cho lane, kh\u00f4ng ph\u1ea3i th\u01b0 m\u1ee5c b\u1ea1n ch\u1ecdn.",
"addLaneRepoLabel": "Repo ngun", "addLaneRepoLabel": "Repo ngu\u1ed3n",
"addLaneTitleLabel": "Tiêu đề", "addLaneTitleLabel": "Ti\u00eau \u0111\u1ec1",
"addLaneTitlePlaceholder": "Không bắt buộc", "addLaneTitlePlaceholder": "Kh\u00f4ng b\u1eaft bu\u1ed9c",
"autoStage": "tự động: {{stage}}", "autoStage": "t\u1ef1 \u0111\u1ed9ng: {{stage}}",
"cardId": "Làn đường {{id}}", "cardId": "L\u00e0n \u0111\u01b0\u1eddng {{id}}",
"confirmRemoveCancel": "Hy", "confirmRemoveCancel": "H\u1ee7y",
"confirmRemoveConfirm": "Xóa", "confirmRemoveConfirm": "X\u00f3a",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.", "confirmRemoveMessage": "H\u00e0nh \u0111\u1ed9ng n\u00e0y kh\u00f4ng th\u1ec3 ho\u00e0n t\u00e1c.",
"confirmRemoveTitle": "Xóa làn đường?", "confirmRemoveTitle": "X\u00f3a l\u00e0n \u0111\u01b0\u1eddng?",
"countDead": "đã chết", "countDead": "\u0111\u00e3 ch\u1ebft",
"countNeedsYou": "cần bạn", "countNeedsYou": "c\u1ea7n b\u1ea1n",
"countRunning": "đang chạy", "countRunning": "\u0111ang ch\u1ea1y",
"countTotal": "làn đường", "countTotal": "l\u00e0n \u0111\u01b0\u1eddng",
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.", "destructive.blocked.adopted": "\u0110\u00e2y l\u00e0 th\u01b0 m\u1ee5c \u0111\u00e3 nh\u1eadn v\u00e0 kh\u00f4ng th\u1ec3 thay \u0111\u1ed5i b\u1eb1ng thao t\u00e1c worktree.",
"destructive.blocked.missing": "Thư mục làn đường không còn, nên không thể tiếp tục thao tác này.", "destructive.blocked.missing": "Th\u01b0 m\u1ee5c l\u00e0n \u0111\u01b0\u1eddng kh\u00f4ng c\u00f2n, n\u00ean kh\u00f4ng th\u1ec3 ti\u1ebfp t\u1ee5c thao t\u00e1c n\u00e0y.",
"destructive.blocked.unreadable": "Không thể đọc thư mục làn đường như một Git worktree, nên không thể tiếp tục thao tác này.", "destructive.blocked.unreadable": "Kh\u00f4ng th\u1ec3 \u0111\u1ecdc th\u01b0 m\u1ee5c l\u00e0n \u0111\u01b0\u1eddng nh\u01b0 m\u1ed9t Git worktree, n\u00ean kh\u00f4ng th\u1ec3 ti\u1ebfp t\u1ee5c thao t\u00e1c n\u00e0y.",
"destructive.cancel": "Hy", "destructive.cancel": "H\u1ee7y",
"destructive.count.bytesEstimate": "Dung lượng giải phóng (ước tính)", "destructive.count.bytesEstimate": "Dung l\u01b0\u1ee3ng gi\u1ea3i ph\u00f3ng (\u01b0\u1edbc t\u00ednh)",
"destructive.count.dirty": "Tệp đã sửa", "destructive.count.dirty": "T\u1ec7p \u0111\u00e3 s\u1eeda",
"destructive.count.events": "Sự kiện", "destructive.count.events": "S\u1ef1 ki\u1ec7n",
"destructive.count.head": "HEAD", "destructive.count.head": "HEAD",
"destructive.count.sessions": "Phiên", "destructive.count.sessions": "Phi\u00ean",
"destructive.count.tokenRows": "Dòng token", "destructive.count.tokenRows": "D\u00f2ng token",
"destructive.count.unpushed": "Commit chưa đẩy", "destructive.count.unpushed": "Commit ch\u01b0a \u0111\u1ea9y",
"destructive.count.untracked": "Tệp chưa theo dõi", "destructive.count.untracked": "T\u1ec7p ch\u01b0a theo d\u00f5i",
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.", "destructive.force": "T\u00f4i hi\u1ec3u c\u00e1c commit ch\u01b0a \u0111\u1ea9y n\u00e0y s\u1ebd b\u1ecb lo\u1ea1i b\u1ecf.",
"destructive.loading": "Đang tải trạng thái hiện tại…", "destructive.loading": "\u0110ang t\u1ea3i tr\u1ea1ng th\u00e1i hi\u1ec7n t\u1ea1i\u2026",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.", "destructive.notice.activeSessionSkipped": "M\u1ed9t ho\u1eb7c nhi\u1ec1u phi\u00ean trong l\u00e0n \u0111\u01b0\u1eddng n\u00e0y v\u1eabn \u0111ang ho\u1ea1t \u0111\u1ed9ng v\u00e0 s\u1ebd \u0111\u01b0\u1ee3c gi\u1eef l\u1ea1i, kh\u00f4ng b\u1ecb d\u1ecdn.",
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.", "destructive.notice.adopted": "L\u00e0n \u0111\u01b0\u1eddng n\u00e0y tr\u1ecf t\u1edbi th\u01b0 m\u1ee5c c\u1ee7a b\u1ea1n. Ch\u1ec9 b\u1ea3n ghi tr\u00ean b\u1ea3ng \u0111i\u1ec1u khi\u1ec3n b\u1ecb x\u00f3a \u2014 th\u01b0 m\u1ee5c v\u00e0 c\u00e1c t\u1ec7p b\u00ean trong kh\u00f4ng b\u1ecb thay \u0111\u1ed5i.",
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.", "destructive.notice.missing": "Th\u01b0 m\u1ee5c l\u00e0n \u0111\u01b0\u1eddng \u0111\u00e3 kh\u00f4ng c\u00f2n. Vi\u1ec7c x\u00f3a ch\u1ec9 b\u1ecf b\u1ea3n ghi tr\u00ean b\u1ea3ng \u0111i\u1ec1u khi\u1ec3n v\u00e0 d\u1ecdn m\u1ee5c worktree c\u0169 trong Git; kh\u00f4ng t\u1ec7p n\u00e0o b\u1ecb x\u00f3a kh\u1ecfi \u0111\u0129a.",
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.", "destructive.notice.unreadable": "Kh\u00f4ng th\u1ec3 \u0111\u1ecdc th\u01b0 m\u1ee5c l\u00e0n \u0111\u01b0\u1eddng nh\u01b0 m\u1ed9t Git worktree. H\u1ec7 th\u1ed1ng s\u1ebd th\u1eed x\u00f3a; n\u1ebfu Git t\u1ef1 ch\u1ed1i, m\u1ee5c ghi worktree c\u1ee7a n\u00f3 s\u1ebd \u0111\u01b0\u1ee3c d\u1ecdn tr\u1ef1c ti\u1ebfp thay th\u1ebf. Trong m\u1ecdi tr\u01b0\u1eddng h\u1ee3p, th\u01b0 m\u1ee5c v\u1eabn kh\u00f4ng b\u1ecb thay \u0111\u1ed5i.",
"destructive.purge.confirm": "Xóa lịch sử", "destructive.purge.confirm": "X\u00f3a l\u1ecbch s\u1eed",
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.", "destructive.purge.message": "H\u00e3y x\u00e1c nh\u1eadn c\u00e1c s\u1ed1 li\u1ec7u hi\u1ec7n t\u1ea1i tr\u01b0\u1edbc khi x\u00f3a l\u1ecbch s\u1eed phi\u00ean \u0111\u00e3 l\u01b0u.",
"destructive.purge.title": "Xóa lịch sử làn đường?", "destructive.purge.title": "X\u00f3a l\u1ecbch s\u1eed l\u00e0n \u0111\u01b0\u1eddng?",
"destructive.remove.confirm": "Xóa làn đường", "destructive.remove.confirm": "X\u00f3a l\u00e0n \u0111\u01b0\u1eddng",
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.", "destructive.remove.message": "H\u00e3y x\u00e1c nh\u1eadn c\u00e1c s\u1ed1 li\u1ec7u hi\u1ec7n t\u1ea1i tr\u01b0\u1edbc khi th\u1ef1c hi\u1ec7n thao t\u00e1c n\u00e0y.",
"destructive.remove.title": "Xóa làn đường?", "destructive.remove.title": "X\u00f3a l\u00e0n \u0111\u01b0\u1eddng?",
"destructive.reset.confirm": "Đặt lại worktree", "destructive.reset.confirm": "\u0110\u1eb7t l\u1ea1i worktree",
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.", "destructive.reset.message": "H\u00e3y x\u00e1c nh\u1eadn c\u00e1c s\u1ed1 li\u1ec7u hi\u1ec7n t\u1ea1i tr\u01b0\u1edbc khi \u0111\u1eb7t l\u1ea1i worktree n\u00e0y.",
"destructive.reset.survives": "Các tệp Git b qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.", "destructive.reset.survives": "C\u00e1c t\u1ec7p Git b\u1ecf qua nh\u01b0 node_modules v\u00e0 .env \u0111\u01b0\u1ee3c gi\u1eef l\u1ea1i; c\u00e1c t\u1ec7p ch\u01b0a theo d\u00f5i s\u1ebd b\u1ecb x\u00f3a.",
"destructive.reset.title": "Đặt lại worktree được quản lý?", "destructive.reset.title": "\u0110\u1eb7t l\u1ea1i worktree \u0111\u01b0\u1ee3c qu\u1ea3n l\u00fd?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.", "destructive.warning.no-remote": "Ch\u01b0a c\u1ea5u h\u00ecnh Git remote, n\u00ean kh\u00f4ng c\u00f3 b\u1ea3n sao l\u01b0u \u1edf xa. B\u1ea1n v\u1eabn c\u00f3 th\u1ec3 ti\u1ebfp t\u1ee5c.",
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:", "emptyState": "Ch\u01b0a c\u00f3 l\u00e0n \u0111\u01b0\u1eddng n\u00e0o. T\u1ea1o m\u1ed9t t\u1eeb th\u01b0 m\u1ee5c l\u00e0m vi\u1ec7c:",
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", "git.uncommitted": "{{dirty}} \u0111\u00e3 s\u1eeda \u00b7 {{untracked}} ch\u01b0a theo d\u00f5i",
"kind.adopted": "đã nhận", "kind.adopted": "\u0111\u00e3 nh\u1eadn",
"kind.managed": "được quản lý", "kind.managed": "\u0111\u01b0\u1ee3c qu\u1ea3n l\u00fd",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", "laneHeader": "L\u00e0n \u0111\u01b0\u1eddng {{id}} \u00b7 {{title}} \u00b7 {{pipeline}}",
"moreActions": "Thêm hành động", "locks.held_one": "\u0110ang gi\u1eef {{count}} kh\u00f3a",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.", "locks.held_other": "\u0110ang gi\u1eef {{count}} kh\u00f3a",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}", "moreActions": "Th\u00eam h\u00e0nh \u0111\u1ed9ng",
"runtime.boot": "▶ chạy", "preflightError": "Kh\u00f4ng th\u1ec3 t\u1ea3i tr\u1ea1ng th\u00e1i l\u00e0n \u0111\u01b0\u1eddng hi\u1ec7n t\u1ea1i.",
"runtime.busy.down": "đang dừng…", "preflightErrorWithMessage": "Kh\u00f4ng th\u1ec3 t\u1ea3i tr\u1ea1ng th\u00e1i l\u00e0n \u0111\u01b0\u1eddng hi\u1ec7n t\u1ea1i: {{message}}",
"runtime.busy.up": "đang khởi động…", "runtime.boot": "\u25b6 ch\u1ea1y",
"runtime.down": "tắt", "runtime.busy.down": "\u0111ang d\u1eebng\u2026",
"runtime.healthy": "khoẻ", "runtime.busy.up": "\u0111ang kh\u1edfi \u0111\u1ed9ng\u2026",
"runtime.partial": "một phần", "runtime.down": "t\u1eaft",
"runtime.healthy": "kho\u1ebb",
"runtime.partial": "m\u1ed9t ph\u1ea7n",
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", "runtime.steppedAsideTitle": "\u0110\u00e3 l\u00f9i kh\u1ecfi {{expected}} v\u00ec c\u1ed5ng \u0111\u00f3 \u0111ang b\u1eadn",
"runtime.stop": "■ dừng", "runtime.stop": "\u25a0 d\u1eebng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)", "runtime.toggleTitle": "Ch\u1ea1y ho\u1eb7c d\u1eebng stack \u1ee9ng d\u1ee5ng c\u1ee7a lane n\u00e0y (kh\u00e1c v\u1edbi phi\u00ean Claude c\u1ee7a n\u00f3)",
"stageUndeclared": "chưa khai báo", "stageUndeclared": "ch\u01b0a khai b\u00e1o",
"status.failed": "thất bại", "status.failed": "th\u1ea5t b\u1ea1i",
"status.idle": "rnh", "status.idle": "r\u1ea3nh",
"status.provisioning": "đang khởi tạo", "status.provisioning": "\u0111ang kh\u1edfi t\u1ea1o",
"status.running": "đang chạy", "status.running": "\u0111ang ch\u1ea1y",
"statusDead": "ĐÃ CHẾT", "statusDead": "\u0110\u00c3 CH\u1ebeT",
"title": "Làn đường", "title": "L\u00e0n \u0111\u01b0\u1eddng",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoc qua tin nhn" "tooltipStart": "T\u1ea1o m\u1ed9t l\u1ea7n ch\u1ea1y \u1edf ch\u1ebf \u0111\u1ed9 h\u1ed9i tho\u1ea1i m\u00e0 kh\u00f4ng c\u00f3 l\u1eddi nh\u1eafc ban \u0111\u1ea7u; \u0111\u01b0\u1ee3c \u0111i\u1ec1u khi\u1ec3n t\u1eeb CLI ho\u1eb7c qua tin nh\u1eafn"
} }
+11
View File
@@ -2014,6 +2014,17 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
}, },
// ──────────────────────────────── Locks API ────────────────────────────────
/** Named locks across all lanes: used for serialization and gating.
* Maps to `server/routes/locks.js`. */
locks: {
/**
* GET /api/locks — list all active named locks and their holders.
* @returns `{ locks }` — all {@link NamedLock} records.
*/
list: (): Promise<{ locks: NamedLock[] }> => request<{ locks: NamedLock[] }>("/locks"),
},
}; };
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+8
View File
@@ -2370,6 +2370,14 @@ export interface LaneRuntimeService {
alive: boolean; alive: boolean;
} }
/** A named lock held by a lane. */
export interface NamedLock {
name: string;
holder: string;
since: number;
ageSec: number;
}
/** /**
* What is actually running for a lane, recomputed by the server on every read * What is actually running for a lane, recomputed by the server on every read
* rather than cached — a process can die without telling anyone. * rather than cached — a process can die without telling anyone.