zigmini 0.8.0-beta.3 → 0.8.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeSlot } from "../runtime/paths.ts";
2
+ import { activeRecord, type SlotRegistry, slotUrl } from "../runtime/slots.ts";
2
3
  import type { OmoClientHandlers, OmoEvent, OmoStatus } from "./client.ts";
3
4
 
4
5
  /**
@@ -55,18 +56,43 @@ type Listed = { sessionId?: unknown; sessionPath?: unknown };
55
56
 
56
57
  const LIVENESS_TIMEOUT_MS = 5000;
57
58
 
59
+ /**
60
+ * Bug 134: an installed gateway gets ZGW_OMO_URL from config.env, and it names this Mac's own slot a bridge. That value
61
+ * must not hide run/runtime-slots.json: when the update handoff restarts the gateway, the generation that still holds the
62
+ * sessions can be slot b. The registry is used unless ZGW_OMO_URL names another OMO (Docker, tests).
63
+ */
64
+ export function startupGenerations(explicitUrl: string | undefined, localUrl: string, registry: () => SlotRegistry | null): { endpoints: GenerationEndpoint[]; active: RuntimeSlot } {
65
+ const url = explicitUrl || localUrl;
66
+ const listed = isLocalBridge(url, localUrl) ? registry() : null;
67
+ if (listed === null) return { endpoints: [{ slot: "a", url }], active: "a" };
68
+ return { endpoints: listed.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) })), active: activeRecord(listed).slot };
69
+ }
70
+
71
+ function isLocalBridge(url: string, localUrl: string): boolean {
72
+ if (!URL.canParse(url)) return false;
73
+ const given = new URL(url);
74
+ const local = new URL(localUrl);
75
+ return given.protocol === local.protocol && given.port === local.port && given.pathname === "/" && given.search === "" &&
76
+ (given.hostname === "127.0.0.1" || given.hostname === "localhost");
77
+ }
78
+
58
79
  /**
59
80
  * Bug 118: after a bridge restart the previous OMO host can still hold its claim on a session file for a moment (the
60
81
  * bridge's SIGTERM ends the host without waiting for it). The new host answers open_session with `session_path_in_use`
61
82
  * and `errorData.retry_after_ms` until that claim is gone; OMO documents the refusal as retryable. The open waits it out
62
83
  * for at most SESSION_PATH_WAIT_MS and never takes the path from the other host.
84
+ * Bug 134: `session_held` (a live holder outside this host's daemon family, e.g. a draining older generation) is waited
85
+ * out the same way; it carries no retry hint, so the open waits SESSION_PATH_RETRY_MS between tries.
63
86
  */
64
87
  export const SESSION_PATH_WAIT_MS = 60_000;
65
88
  export const SESSION_PATH_RETRY_MS = 2000;
66
89
 
67
- /** Milliseconds to wait before reopening after this error, or null when the error is not a held session path. */
90
+ /** Milliseconds to wait before reopening after this error, or null when the error is not a held session. */
68
91
  export function sessionPathRetryMs(error: unknown): number | null {
69
- if (typeof error !== "object" || error === null || (error as { code?: unknown }).code !== "session_path_in_use") return null;
92
+ if (typeof error !== "object" || error === null) return null;
93
+ const code = (error as { code?: unknown }).code;
94
+ if (code === "session_held") return SESSION_PATH_RETRY_MS;
95
+ if (code !== "session_path_in_use") return null;
70
96
  const data = (error as { errorData?: unknown }).errorData;
71
97
  const hint = typeof data === "object" && data !== null ? (data as { retry_after_ms?: unknown }).retry_after_ms : undefined;
72
98
  return typeof hint === "number" && hint > 0 ? hint : SESSION_PATH_RETRY_MS;
package/web/app.js CHANGED
@@ -41,6 +41,7 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
41
41
  import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
42
42
  import { createAppDownload } from "./app-download.js";
43
43
  import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
44
+ import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
44
45
 
45
46
  const $ = (id) => document.getElementById(id);
46
47
  const state = {
@@ -3689,4 +3690,93 @@ settings.toggle.onclick = () => (settings.open ? closeSettings() : openSettings(
3689
3690
  settings.close.onclick = closeSettings;
3690
3691
  enrollEls.renew.onclick = () => void enrollment.renew();
3691
3692
 
3693
+ // 설정 "매뉴얼": GET /api/manual (Korean first) rendered with the chat Markdown renderer; the first-line language links
3694
+ // and the header button switch to the other language in place.
3695
+ const manualEls = { el: $("manual"), open: $("manual-open"), close: $("manual-close"), title: $("manual-title"), lang: $("manual-lang"), status: $("manual-status"), body: $("manual-body") };
3696
+ const manualView = { open: false, lang: "ko", load: 0 };
3697
+
3698
+ function showManualStatus(text, error = false) {
3699
+ manualEls.status.textContent = text;
3700
+ manualEls.status.classList.toggle("error", error);
3701
+ manualEls.status.hidden = text === "";
3702
+ }
3703
+
3704
+ async function loadManual(lang) {
3705
+ const load = ++manualView.load;
3706
+ manualView.lang = lang;
3707
+ manualEls.lang.textContent = otherLang(lang) === "en" ? "English" : "한국어";
3708
+ manualEls.el.lang = lang;
3709
+ showManualStatus("매뉴얼을 불러오는 중입니다.");
3710
+ let status = 0;
3711
+ let text = "";
3712
+ try {
3713
+ const res = await fetch(manualUrl(lang));
3714
+ status = res.status;
3715
+ if (res.ok) text = await res.text();
3716
+ } catch (error) {
3717
+ console.warn("manual:", error);
3718
+ }
3719
+ if (load !== manualView.load) return;
3720
+ if (text === "") {
3721
+ manualEls.body.innerHTML = "";
3722
+ showManualStatus(manualFailureText(status), true);
3723
+ return;
3724
+ }
3725
+ showManualStatus("");
3726
+ renderMarkdown(manualEls.body, text);
3727
+ manualEls.body.scrollTop = 0;
3728
+ }
3729
+
3730
+ function openManual() {
3731
+ if (manualView.open) return;
3732
+ manualView.open = true;
3733
+ manualEls.el.hidden = false;
3734
+ manualEls.open.setAttribute("aria-expanded", "true");
3735
+ manualEls.title.focus();
3736
+ void loadManual(manualView.lang);
3737
+ }
3738
+
3739
+ function closeManual() {
3740
+ if (!manualView.open) return;
3741
+ manualView.open = false;
3742
+ manualView.load++;
3743
+ manualEls.el.hidden = true;
3744
+ manualEls.open.setAttribute("aria-expanded", "false");
3745
+ manualEls.open.focus();
3746
+ }
3747
+
3748
+ manualEls.open.onclick = openManual;
3749
+ manualEls.close.onclick = closeManual;
3750
+ manualEls.lang.onclick = () => void loadManual(otherLang(manualView.lang));
3751
+ manualEls.body.addEventListener("click", (ev) => {
3752
+ const link = ev.target instanceof Element ? ev.target.closest("a") : null;
3753
+ const lang = manualLinkLang(link?.getAttribute("href"));
3754
+ if (lang === null) return;
3755
+ ev.preventDefault();
3756
+ void loadManual(lang);
3757
+ });
3758
+ manualEls.el.addEventListener("click", (ev) => {
3759
+ if (ev.target === manualEls.el) closeManual();
3760
+ });
3761
+ manualEls.el.addEventListener("keydown", (ev) => {
3762
+ if (ev.isComposing) return;
3763
+ if (ev.key === "Escape") {
3764
+ ev.preventDefault();
3765
+ closeManual();
3766
+ return;
3767
+ }
3768
+ if (ev.key !== "Tab") return;
3769
+ const focusable = [...manualEls.el.querySelectorAll(FOCUSABLE)].filter((node) => node.offsetParent !== null);
3770
+ if (focusable.length === 0) return;
3771
+ const first = focusable[0];
3772
+ const last = focusable.at(-1);
3773
+ if (ev.shiftKey && (document.activeElement === first || document.activeElement === manualEls.title)) {
3774
+ ev.preventDefault();
3775
+ last.focus();
3776
+ } else if (!ev.shiftKey && document.activeElement === last) {
3777
+ ev.preventDefault();
3778
+ first.focus();
3779
+ }
3780
+ });
3781
+
3692
3782
  connect();
package/web/index.html CHANGED
@@ -122,6 +122,7 @@
122
122
  <div class="settings-card">
123
123
  <header class="settings-head">
124
124
  <h2 class="settings-title" id="settings-title" tabindex="-1">설정</h2>
125
+ <button type="button" class="text-btn" id="manual-open" title="사용자 매뉴얼: 화면, 단축키, 기능" aria-haspopup="dialog" aria-expanded="false" aria-controls="manual">매뉴얼</button>
125
126
  <button type="button" class="icon-btn settings-close" id="settings-close" aria-label="설정 닫기" title="닫기 (Esc)">×</button>
126
127
  </header>
127
128
  <div class="settings-body">
@@ -185,6 +186,17 @@
185
186
  </div>
186
187
  </div>
187
188
  </aside>
189
+ <aside class="settings manual" id="manual" role="dialog" aria-labelledby="manual-title" hidden>
190
+ <div class="settings-card manual-card">
191
+ <header class="settings-head">
192
+ <h2 class="settings-title" id="manual-title" tabindex="-1">매뉴얼</h2>
193
+ <button type="button" class="text-btn" id="manual-lang">English</button>
194
+ <button type="button" class="icon-btn settings-close" id="manual-close" aria-label="매뉴얼 닫기" title="닫기 (Esc)">×</button>
195
+ </header>
196
+ <p class="manual-status" id="manual-status" role="status" aria-live="polite" hidden></p>
197
+ <div class="manual-body msg-text" id="manual-body"></div>
198
+ </div>
199
+ </aside>
188
200
  <aside class="dev" id="dev">
189
201
  <header class="dev-head">
190
202
  <span>이벤트</span>
@@ -0,0 +1,7 @@
1
+ export type ManualLang = "ko" | "en";
2
+ export declare const MANUAL_PATH: string;
3
+ export declare const MANUAL_MISSING_TEXT: string;
4
+ export declare function manualUrl(lang: string): string;
5
+ export declare function otherLang(lang: string): ManualLang;
6
+ export declare function manualLinkLang(href: string | null | undefined): ManualLang | null;
7
+ export declare function manualFailureText(status: number): string;
package/web/manual.js ADDED
@@ -0,0 +1,29 @@
1
+ // The user manual view of 설정 "매뉴얼": decisions only, with no DOM, so test/unit/manual.test.ts and the mutation run
2
+ // drive them directly; app.js fetches GET /api/manual and renders the Markdown with the chat renderer.
3
+
4
+ export const MANUAL_PATH = "/api/manual";
5
+ export const MANUAL_MISSING_TEXT = "이 게이트웨이 버전에는 매뉴얼이 없습니다. 게이트웨이를 업데이트하면 볼 수 있습니다.";
6
+
7
+ export function manualUrl(lang) {
8
+ return `${MANUAL_PATH}?lang=${lang === "en" ? "en" : "ko"}`;
9
+ }
10
+
11
+ export function otherLang(lang) {
12
+ return lang === "en" ? "ko" : "en";
13
+ }
14
+
15
+ /** The language a rendered link switches to: the manuals' first line links manual.md (English) and manual.ko.md (Korean). */
16
+ export function manualLinkLang(href) {
17
+ if (typeof href !== "string") return null;
18
+ const file = href.split(/[?#]/)[0].split("/").pop();
19
+ if (file === "manual.md") return "en";
20
+ if (file === "manual.ko.md") return "ko";
21
+ return null;
22
+ }
23
+
24
+ /** The line for a failed load: [status] is the HTTP status, or 0 when the request itself failed. */
25
+ export function manualFailureText(status) {
26
+ if (status === 404) return MANUAL_MISSING_TEXT;
27
+ if (status === 0) return "매뉴얼을 불러오지 못했습니다. 게이트웨이 연결을 확인하세요.";
28
+ return `매뉴얼을 불러오지 못했습니다 (HTTP ${status}).`;
29
+ }
package/web/style.css CHANGED
@@ -423,6 +423,11 @@ button { font: inherit; color: inherit; cursor: pointer; }
423
423
  .gc-message:empty { display: none; }
424
424
  .gc-message.error { color: #f38b8d; }
425
425
  .gc-actions { display: flex; justify-content: flex-end; }
426
+ .settings.manual { z-index: 31; }
427
+ .manual-card { width: min(100%, 760px); height: 100%; }
428
+ .manual-status { margin: 0; padding: 12px 16px 0; font-size: 13px; color: var(--muted); }
429
+ .manual-status.error { color: #f38b8d; }
430
+ .manual-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px; }
426
431
  @media (max-width: 640px) {
427
432
  .settings { padding: 0; }
428
433
  .settings-card { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }