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 { api } from "../../lib/api";
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:
* 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. */
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
* 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;
}
/**
* 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> = {
active: "bg-status-success",
idle: "bg-surface-4",
@@ -125,6 +160,7 @@ export default function LaneCard({
const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
/**
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
@@ -302,6 +338,16 @@ export default function LaneCard({
</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">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
@@ -24,6 +24,9 @@ vi.mock("../../../lib/api", () => ({
down: vi.fn(),
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).mockResolvedValue({ available: false });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
function makeLane(overrides: Partial<Lane> = {}): Lane {
return {
@@ -335,3 +340,24 @@ describe("LaneCard — the lane's own application stack", () => {
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",
"addLane": "Create a lane from a working directory",
"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.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoLabel": "Source repository",
@@ -40,9 +40,9 @@
"destructive.count.unpushed": "Unpushed commits",
"destructive.count.untracked": "Untracked files",
"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.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.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",
@@ -57,22 +57,24 @@
"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.",
"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.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",
"preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"runtime.boot": " up",
"runtime.busy.down": "stopping",
"runtime.busy.up": "booting",
"runtime.boot": "\u25b6 up",
"runtime.busy.down": "stopping\u2026",
"runtime.busy.up": "booting\u2026",
"runtime.down": "down",
"runtime.healthy": "healthy",
"runtime.partial": "partial",
"runtime.slot": "slot {{slot}}",
"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)",
"stageUndeclared": "not declared",
"status.failed": "failed",
+83 -81
View File
@@ -1,85 +1,87 @@
{
"action.clear": "dọn trạng thái",
"action.forget": "xóa làn",
"action.purge": "xóa lịch sử",
"action.remove": "xóa làn + worktree",
"action.reset": "đặt lại worktree",
"action.start": "bắt đầu",
"action.stop": "dng",
"actionError": "Thao tác làn đường thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định",
"add": "Thêm lane",
"addLane": "To lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"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.",
"addLaneRepoLabel": "Repo ngun",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}",
"cardId": "Làn đường {{id}}",
"confirmRemoveCancel": "Hy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
"confirmRemoveTitle": "Xóa làn đường?",
"countDead": "đã chết",
"countNeedsYou": "cần bạn",
"countRunning": "đang chạy",
"countTotal": "làn đường",
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác 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.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.cancel": "Hy",
"destructive.count.bytesEstimate": "Dung lượng giải phóng (ước tính)",
"destructive.count.dirty": "Tệp đã sửa",
"destructive.count.events": "Sự kiện",
"action.clear": "d\u1ecdn tr\u1ea1ng th\u00e1i",
"action.forget": "x\u00f3a l\u00e0n",
"action.purge": "x\u00f3a l\u1ecbch s\u1eed",
"action.remove": "x\u00f3a l\u00e0n + worktree",
"action.reset": "\u0111\u1eb7t l\u1ea1i worktree",
"action.start": "b\u1eaft \u0111\u1ea7u",
"action.stop": "d\u1eebng",
"actionError": "Thao t\u00e1c l\u00e0n \u0111\u01b0\u1eddng th\u1ea5t b\u1ea1i: {{message}}",
"actionErrorUnknown": "L\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh",
"add": "Th\u00eam lane",
"addLane": "T\u1ea1o lane t\u1eeb m\u1ed9t th\u01b0 m\u1ee5c l\u00e0m vi\u1ec7c",
"addLaneBaseLabel": "Nh\u00e1nh \u0111\u1ec3 t\u1ea1o nh\u00e1nh m\u1edbi",
"addLaneNoBranches": "Repo n\u00e0y ch\u01b0a c\u00f3 commit n\u00e0o \u2014 worktree s\u1ebd b\u1eaft \u0111\u1ea7u tr\u1ed1ng.",
"addLaneNotARepo": "Ch\u01b0a ph\u1ea3i repo git (ho\u1eb7c kh\u00f4ng c\u00f3 quy\u1ec1n \u0111\u1ecdc).",
"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 ngu\u1ed3n",
"addLaneTitleLabel": "Ti\u00eau \u0111\u1ec1",
"addLaneTitlePlaceholder": "Kh\u00f4ng b\u1eaft bu\u1ed9c",
"autoStage": "t\u1ef1 \u0111\u1ed9ng: {{stage}}",
"cardId": "L\u00e0n \u0111\u01b0\u1eddng {{id}}",
"confirmRemoveCancel": "H\u1ee7y",
"confirmRemoveConfirm": "X\u00f3a",
"confirmRemoveMessage": "H\u00e0nh \u0111\u1ed9ng n\u00e0y kh\u00f4ng th\u1ec3 ho\u00e0n t\u00e1c.",
"confirmRemoveTitle": "X\u00f3a l\u00e0n \u0111\u01b0\u1eddng?",
"countDead": "\u0111\u00e3 ch\u1ebft",
"countNeedsYou": "c\u1ea7n b\u1ea1n",
"countRunning": "\u0111ang ch\u1ea1y",
"countTotal": "l\u00e0n \u0111\u01b0\u1eddng",
"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\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\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": "H\u1ee7y",
"destructive.count.bytesEstimate": "Dung l\u01b0\u1ee3ng gi\u1ea3i ph\u00f3ng (\u01b0\u1edbc t\u00ednh)",
"destructive.count.dirty": "T\u1ec7p \u0111\u00e3 s\u1eeda",
"destructive.count.events": "S\u1ef1 ki\u1ec7n",
"destructive.count.head": "HEAD",
"destructive.count.sessions": "Phiên",
"destructive.count.tokenRows": "Dòng token",
"destructive.count.unpushed": "Commit chưa đẩy",
"destructive.count.untracked": "Tệp chưa theo dõi",
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
"destructive.loading": "Đang tải trạng thái hiện tại…",
"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.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.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.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.purge.confirm": "Xóa lịch sử",
"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.title": "Xóa lịch sử làn đường?",
"destructive.remove.confirm": "Xóa làn đường",
"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.title": "Xóa làn đường?",
"destructive.reset.confirm": "Đặt lại 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.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.title": "Đặt lại worktree được quản lý?",
"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.",
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"moreActions": "Thêm hành động",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
"runtime.down": "tắt",
"runtime.healthy": "khoẻ",
"runtime.partial": "một phần",
"destructive.count.sessions": "Phi\u00ean",
"destructive.count.tokenRows": "D\u00f2ng token",
"destructive.count.unpushed": "Commit ch\u01b0a \u0111\u1ea9y",
"destructive.count.untracked": "T\u1ec7p ch\u01b0a theo d\u00f5i",
"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": "\u0110ang t\u1ea3i tr\u1ea1ng th\u00e1i hi\u1ec7n t\u1ea1i\u2026",
"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\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\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\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\u00f3a l\u1ecbch s\u1eed",
"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\u00f3a l\u1ecbch s\u1eed l\u00e0n \u0111\u01b0\u1eddng?",
"destructive.remove.confirm": "X\u00f3a l\u00e0n \u0111\u01b0\u1eddng",
"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\u00f3a l\u00e0n \u0111\u01b0\u1eddng?",
"destructive.reset.confirm": "\u0110\u1eb7t l\u1ea1i worktree",
"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\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": "\u0110\u1eb7t l\u1ea1i worktree \u0111\u01b0\u1ee3c qu\u1ea3n l\u00fd?",
"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\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}} \u0111\u00e3 s\u1eeda \u00b7 {{untracked}} ch\u01b0a theo d\u00f5i",
"kind.adopted": "\u0111\u00e3 nh\u1eadn",
"kind.managed": "\u0111\u01b0\u1ee3c qu\u1ea3n l\u00fd",
"laneHeader": "L\u00e0n \u0111\u01b0\u1eddng {{id}} \u00b7 {{title}} \u00b7 {{pipeline}}",
"locks.held_one": "\u0110ang gi\u1eef {{count}} kh\u00f3a",
"locks.held_other": "\u0110ang gi\u1eef {{count}} kh\u00f3a",
"moreActions": "Th\u00eam h\u00e0nh \u0111\u1ed9ng",
"preflightError": "Kh\u00f4ng th\u1ec3 t\u1ea3i tr\u1ea1ng th\u00e1i l\u00e0n \u0111\u01b0\u1eddng hi\u1ec7n t\u1ea1i.",
"preflightErrorWithMessage": "Kh\u00f4ng th\u1ec3 t\u1ea3i tr\u1ea1ng th\u00e1i l\u00e0n \u0111\u01b0\u1eddng hi\u1ec7n t\u1ea1i: {{message}}",
"runtime.boot": "\u25b6 ch\u1ea1y",
"runtime.busy.down": "\u0111ang d\u1eebng\u2026",
"runtime.busy.up": "\u0111ang kh\u1edfi \u0111\u1ed9ng\u2026",
"runtime.down": "t\u1eaft",
"runtime.healthy": "kho\u1ebb",
"runtime.partial": "m\u1ed9t ph\u1ea7n",
"runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"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ó)",
"stageUndeclared": "chưa khai báo",
"status.failed": "thất bại",
"status.idle": "rnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"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"
"runtime.steppedAsideTitle": "\u0110\u00e3 l\u00f9i kh\u1ecfi {{expected}} v\u00ec c\u1ed5ng \u0111\u00f3 \u0111ang b\u1eadn",
"runtime.stop": "\u25a0 d\u1eebng",
"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\u01b0a khai b\u00e1o",
"status.failed": "th\u1ea5t b\u1ea1i",
"status.idle": "r\u1ea3nh",
"status.provisioning": "\u0111ang kh\u1edfi t\u1ea1o",
"status.running": "\u0111ang ch\u1ea1y",
"statusDead": "\u0110\u00c3 CH\u1ebeT",
"title": "L\u00e0n \u0111\u01b0\u1eddng",
"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),
}),
},
// ──────────────────────────────── 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;
}
/** 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
* rather than cached — a process can die without telling anyone.