diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 588192a..9c654a1 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -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([]); + + 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 = { active: "bg-status-success", idle: "bg-surface-4", @@ -125,6 +160,7 @@ export default function LaneCard({ const [bootLine, setBootLine] = useState(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({ )} + {locks.length > 0 && ( +
`${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")} + > + 🔒 {t("locks.held", { count: locks.length })} +
+ )} +
{git?.available && (
diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index b7d9b12..913ca68 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -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 { 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(); + + 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(); + + expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 3d42e0f..aafb239 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -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", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 2b2c688..7e00e7b 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -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": "dừng", - "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": "Tạo 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": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", - "addLaneRepoLabel": "Repo nguồn", - "addLaneTitleLabel": "Tiêu đề", - "addLaneTitlePlaceholder": "Không bắt buộc", - "autoStage": "tự động: {{stage}}", - "cardId": "Làn đường {{id}}", - "confirmRemoveCancel": "Hủy", - "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": "Hủy", - "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": "rảnh", - "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 hoặc qua tin nhắn" + "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" } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ee91f4b..f6e50c8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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"), + }, }; // ───────────────────────────────────────────────────────────────────────────── diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 3ad8d7c..1c7fad2 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -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.