zigmini 0.8.0-beta.2 → 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.
- package/README.ko.md +267 -0
- package/README.md +155 -129
- package/assets/agent/gateway-rules.md +1 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +382 -0
- package/docs/manual.md +382 -0
- package/host/omo-bridge/continuity-control.ts +3 -25
- package/host/omo-bridge/task-control.ts +7 -4
- package/package.json +6 -3
- package/src/cli/main.ts +5 -0
- package/src/cli/setup-hint.ts +25 -0
- package/src/client-topics.ts +16 -0
- package/src/decisions.ts +22 -5
- package/src/hub.ts +22 -10
- package/src/main.ts +16 -12
- package/src/manual.ts +31 -0
- package/src/omo/generations.ts +28 -2
- package/src/store.ts +15 -4
- package/src/work.ts +6 -2
- package/web/app.js +90 -0
- package/web/index.html +12 -0
- package/web/manual.d.ts +7 -0
- package/web/manual.js +29 -0
- package/web/style.css +5 -0
package/src/hub.ts
CHANGED
|
@@ -186,6 +186,7 @@ interface QuestionItem {
|
|
|
186
186
|
export interface ScopedQuestionFrame {
|
|
187
187
|
id: string;
|
|
188
188
|
toolCallId?: string;
|
|
189
|
+
waitForAnswer?: boolean;
|
|
189
190
|
questions: QuestionItem[];
|
|
190
191
|
askedAtMs?: number;
|
|
191
192
|
deadlineAtMs?: number;
|
|
@@ -1126,7 +1127,8 @@ export class Hub {
|
|
|
1126
1127
|
) {
|
|
1127
1128
|
throw new Error("turn_running");
|
|
1128
1129
|
}
|
|
1129
|
-
|
|
1130
|
+
// Any rotation hands main's open waits of the closed session to the next one (inbox resume); bug 72.
|
|
1131
|
+
this.retireScopedQuestions(convId, sessionId);
|
|
1130
1132
|
try {
|
|
1131
1133
|
await this.requireOmo().command("close_session", { sessionId });
|
|
1132
1134
|
const listed = await this.requireOmo().command<{
|
|
@@ -1227,9 +1229,9 @@ export class Hub {
|
|
|
1227
1229
|
return owned;
|
|
1228
1230
|
}
|
|
1229
1231
|
|
|
1230
|
-
/** What holds automatic rotation: the rotation
|
|
1232
|
+
/** What holds automatic rotation: the age rotation holds plus an unconfirmed session close. */
|
|
1231
1233
|
private rotationHolds(conv: string): RotationBlocker[] {
|
|
1232
|
-
const holds = this.
|
|
1234
|
+
const holds = this.rotationHoldsFor(conv, false);
|
|
1233
1235
|
return this.closeUncertain(conv) ? [{ kind: "session_close_unknown" }, ...holds] : holds;
|
|
1234
1236
|
}
|
|
1235
1237
|
|
|
@@ -1294,9 +1296,18 @@ export class Hub {
|
|
|
1294
1296
|
}
|
|
1295
1297
|
}
|
|
1296
1298
|
|
|
1297
|
-
/** What holds a rotation now: every rotation blocker, or for a forced rotation only an unfinished main turn. */
|
|
1298
1299
|
private rotationHeldFor(conv: string, forced: boolean): boolean {
|
|
1299
|
-
return
|
|
1300
|
+
return this.rotationHoldsFor(conv, forced).length > 0;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Bugs 72 and 86: what holds a rotation now. A main turn always holds; an age or manual rotation also waits for an open
|
|
1305
|
+
* approval dialog or native question. Works, their unreported reports and inbox decisions belong to the conversation,
|
|
1306
|
+
* not the session file, so they carry over to the next session (handoff, ZGW_WORK_STATE, work update wake) and never hold.
|
|
1307
|
+
*/
|
|
1308
|
+
private rotationHoldsFor(conv: string, forced: boolean): RotationBlocker[] {
|
|
1309
|
+
return this.rotationBlockers(conv, false).filter((blocker) =>
|
|
1310
|
+
blocker.kind === "main_turn" || (!forced && (blocker.kind === "dialog" || blocker.kind === "question")));
|
|
1300
1311
|
}
|
|
1301
1312
|
|
|
1302
1313
|
/** Bug 72 proposal 4: the user-confirmed limit this conversation's session reached, if any. */
|
|
@@ -1334,10 +1345,10 @@ export class Hub {
|
|
|
1334
1345
|
}
|
|
1335
1346
|
|
|
1336
1347
|
/** Bug 72 proposal 4: what stays open across the rotation, so the next session owns it. */
|
|
1337
|
-
private handoffOpenItems(conv: string
|
|
1348
|
+
private handoffOpenItems(conv: string): string[] {
|
|
1338
1349
|
const open = this.ownedOpenDecisions(conv);
|
|
1339
1350
|
return [
|
|
1340
|
-
|
|
1351
|
+
this.workSummary(conv).length > 0
|
|
1341
1352
|
? "끝나지 않은 작업은 그대로 이어집니다. 목록은 ZGW_WORK_STATE와 zgw_work_list로 보고, 보고하지 않은 결과는 작업 알림으로 다시 받습니다."
|
|
1342
1353
|
: "",
|
|
1343
1354
|
...(open.length > 0
|
|
@@ -1347,7 +1358,7 @@ export class Hub {
|
|
|
1347
1358
|
}
|
|
1348
1359
|
|
|
1349
1360
|
private rotationEligible(conv: ConversationInfo): boolean {
|
|
1350
|
-
if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.
|
|
1361
|
+
if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.rotationHeldFor(conv.id, false)) return false;
|
|
1351
1362
|
const started = Date.parse(this.store.getSessionMeta(conv.id).startedAt ?? conv.createdAt);
|
|
1352
1363
|
const used = this.store.lastSeq(conv.id, "message.user") > this.store.lastSeq(conv.id, "session.rotated");
|
|
1353
1364
|
if (!used) return false;
|
|
@@ -1387,7 +1398,7 @@ export class Hub {
|
|
|
1387
1398
|
return [
|
|
1388
1399
|
`[게이트웨이 알림] 이 대화는 새 omo 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1389
1400
|
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
|
|
1390
|
-
...this.handoffOpenItems(conv
|
|
1401
|
+
...this.handoffOpenItems(conv),
|
|
1391
1402
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
1392
1403
|
"---",
|
|
1393
1404
|
...lines,
|
|
@@ -1786,7 +1797,8 @@ export class Hub {
|
|
|
1786
1797
|
const decision = this.decisions?.observe(owner, {
|
|
1787
1798
|
actionId, sourceRequestId: frame.id, method: "question", permission: false,
|
|
1788
1799
|
payload: { title: card.title, detail: card.detail, options: [], questions: frame.questions,
|
|
1789
|
-
...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {})
|
|
1800
|
+
...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {}),
|
|
1801
|
+
...(frame.waitForAnswer === false ? { waitForAnswer: false } : {}) },
|
|
1790
1802
|
deadlineAt: frame.deadlineAtMs ?? null,
|
|
1791
1803
|
});
|
|
1792
1804
|
const open = this.openQuestionCards(owner.conv, owner.scope);
|
package/src/main.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
SYSTEM_NETWORK,
|
|
30
30
|
} from "./enroll.ts";
|
|
31
31
|
import { ACCESS_PATH, handleAccessApi, pingAccess, systemTailscale } from "./cli/access.ts";
|
|
32
|
+
import { CHAT_TOPIC, showsThinking, THINKING_TOPIC, topicOf } from "./client-topics.ts";
|
|
32
33
|
import { APK_DIR, APK_PATH, APP_DOWNLOAD_LINK_PATH, APP_VERSION_PATH, ApkSource, appVersion, createDownloadLink, DownloadLinks, serveApk } from "./app-download.ts";
|
|
33
34
|
import { loadConfig } from "./config.ts";
|
|
34
35
|
import { memoUpdatedData, parseMemoPatch } from "./memo-images.ts";
|
|
@@ -38,7 +39,7 @@ import { Hub, type ScopedQuestionFrame } from "./hub.ts";
|
|
|
38
39
|
import { parseSummaryModels, SpeechSummarizer } from "./speech-summary.ts";
|
|
39
40
|
import { MEDIA_MAX_BYTES, saveUpload, serveMedia } from "./media.ts";
|
|
40
41
|
import { OmoClient } from "./omo/client.ts";
|
|
41
|
-
import { bridgeLiveness, GenerationRouter,
|
|
42
|
+
import { bridgeLiveness, GenerationRouter, startupGenerations } from "./omo/generations.ts";
|
|
42
43
|
import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
|
|
43
44
|
import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
|
|
44
45
|
import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
|
|
@@ -65,6 +66,7 @@ import { acceptUpdateConsent, requestUpdate, type UpdateApiDeps } from "./update
|
|
|
65
66
|
import { PHASE_LABELS, readState, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, updateDir, UpdateWatcher } from "./update/state.ts";
|
|
66
67
|
import { resolveTarget, tarEntry } from "./update/target.ts";
|
|
67
68
|
import { registryUrl, UPDATE_CHECK_PATH, UPDATE_STATUS_PATH, UpdateChecker, versionAvailableEnvelope } from "./update/check.ts";
|
|
69
|
+
import { MANUAL_PATH, serveManual } from "./manual.ts";
|
|
68
70
|
import { clean as gcClean, GC_CLEAN_PATH, GC_PATH, type GcRoots, parseSelection as parseGcSelection, report as gcReport, systemGcDeps } from "./gc.ts";
|
|
69
71
|
|
|
70
72
|
// Exit 0 so launchd (KeepAlive SuccessfulExit=false) does not restart a gateway that has no consent.
|
|
@@ -142,7 +144,6 @@ const updateApi: UpdateApiDeps = {
|
|
|
142
144
|
now: () => new Date(),
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
const TOPIC = "clients";
|
|
146
147
|
let publish: (envelope: Envelope) => void = () => {};
|
|
147
148
|
const hub = new Hub(
|
|
148
149
|
store,
|
|
@@ -189,14 +190,11 @@ function requesterId(slot: RuntimeSlot): string {
|
|
|
189
190
|
store.setSetting(key, id);
|
|
190
191
|
return id;
|
|
191
192
|
}
|
|
192
|
-
// Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with
|
|
193
|
-
//
|
|
194
|
-
const
|
|
195
|
-
const endpoints: GenerationEndpoint[] = slotRegistry === null
|
|
196
|
-
? [{ slot: "a", url: config.omoUrl }]
|
|
197
|
-
: slotRegistry.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) }));
|
|
193
|
+
// Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with a
|
|
194
|
+
// ZGW_OMO_URL that names another OMO, as in Docker and the tests) it has one generation, as before (bug 134).
|
|
195
|
+
const startup = startupGenerations(process.env.ZGW_OMO_URL, `ws://127.0.0.1:${runtime.ports.bridge}`, () => readRegistry(runtime.slotsFile));
|
|
198
196
|
let connectionGeneration = 0;
|
|
199
|
-
const omo = new GenerationRouter(endpoints,
|
|
197
|
+
const omo = new GenerationRouter(startup.endpoints, startup.active, {
|
|
200
198
|
connect: (endpoint, handlers) => new OmoClient(endpoint.url, omoToken, handlers, requesterId(endpoint.slot)),
|
|
201
199
|
liveness: (endpoint) => bridgeLiveness(endpoint.url, omoToken),
|
|
202
200
|
log: (line) => console.log(line),
|
|
@@ -397,6 +395,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
397
395
|
// question cannot race into a phone notification between those adjacent frames.
|
|
398
396
|
questionNotifications.setWebPresence(ws, true);
|
|
399
397
|
}
|
|
398
|
+
if (!showsThinking(msg.data?.client)) ws.unsubscribe(THINKING_TOPIC);
|
|
400
399
|
const state = hub.snapshot();
|
|
401
400
|
const snapshot = { ...state, approvals: omoCheckNotifications.filterApprovals(state.approvals) };
|
|
402
401
|
// While omo is reconnecting, Hub intentionally clears its in-memory question cards until
|
|
@@ -628,6 +627,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
628
627
|
const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
|
|
629
628
|
if (response) return response;
|
|
630
629
|
}
|
|
630
|
+
if (url.pathname === MANUAL_PATH) return serveManual(req, url);
|
|
631
631
|
// Plan Todo 23: the update button. The app port may ask too, but never for an update that needs a new consent.
|
|
632
632
|
if (url.pathname === UPDATE_PATH && req.method === "POST") {
|
|
633
633
|
const body = await req.json().catch(() => null) as { origin?: unknown } | null;
|
|
@@ -806,7 +806,10 @@ async function routeApi(req: Request, url: URL): Promise<Response> {
|
|
|
806
806
|
|
|
807
807
|
const websocket: WebSocketHandler<Conn> = {
|
|
808
808
|
open(ws) {
|
|
809
|
-
if (!ws.data.notifyOnly)
|
|
809
|
+
if (!ws.data.notifyOnly) {
|
|
810
|
+
ws.subscribe(CHAT_TOPIC);
|
|
811
|
+
ws.subscribe(THINKING_TOPIC);
|
|
812
|
+
}
|
|
810
813
|
if (ws.data.trusted && !ws.data.notifyOnly) ws.subscribe(TRUSTED_TOPIC);
|
|
811
814
|
const id = ws.data.deviceId;
|
|
812
815
|
if (id) {
|
|
@@ -837,8 +840,9 @@ publish = (e) => {
|
|
|
837
840
|
if (!omoCheckNotifications.allow(e)) return;
|
|
838
841
|
questionNotifications.onEnvelope(e);
|
|
839
842
|
const text = JSON.stringify(e);
|
|
840
|
-
|
|
841
|
-
|
|
843
|
+
const topic = topicOf(e.type);
|
|
844
|
+
trustedServer.publish(topic, text);
|
|
845
|
+
lanServer?.publish(topic, text);
|
|
842
846
|
// A decision change can change a work's 입력 대기 state (its open decisions, or its status after a delivered answer).
|
|
843
847
|
if (e.type === "activity.updated" || e.type === "conversation.created" || e.type === "decision.updated") workStatus.schedule();
|
|
844
848
|
};
|
package/src/manual.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user manual (docs/manual.md, docs/manual.ko.md) for the web 설정 "매뉴얼" button and the app's work status screen.
|
|
5
|
+
* Read only, on the web port and, behind the app device gate, on the app port. Korean is the default language.
|
|
6
|
+
*/
|
|
7
|
+
export const MANUAL_PATH = "/api/manual";
|
|
8
|
+
export const MANUAL_DIR = join(import.meta.dir, "..", "docs");
|
|
9
|
+
export type ManualLang = "ko" | "en";
|
|
10
|
+
|
|
11
|
+
/** The `lang` query value as a language: missing or empty is Korean; anything but `ko` or `en` is null. */
|
|
12
|
+
export function manualLang(value: string | null): ManualLang | null {
|
|
13
|
+
if (value === null || value === "" || value === "ko") return "ko";
|
|
14
|
+
return value === "en" ? "en" : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function manualFile(lang: ManualLang): string {
|
|
18
|
+
return lang === "en" ? "manual.md" : "manual.ko.md";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `GET /api/manual?lang=ko|en`: the Markdown text; 400 for another language, 404 with a hint when the file is missing. */
|
|
22
|
+
export async function serveManual(req: Request, url: URL, dir: string = MANUAL_DIR): Promise<Response> {
|
|
23
|
+
if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
24
|
+
const lang = manualLang(url.searchParams.get("lang"));
|
|
25
|
+
if (lang === null) return Response.json({ error: "unknown_lang", message: "lang은 ko 또는 en입니다." }, { status: 400 });
|
|
26
|
+
const file = Bun.file(join(dir, manualFile(lang)));
|
|
27
|
+
if (!(await file.exists())) {
|
|
28
|
+
return Response.json({ error: "manual_missing", message: "이 설치에는 매뉴얼 파일이 없습니다. 게이트웨이를 업데이트하세요." }, { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
return new Response(file, { headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-cache", "content-language": lang } });
|
|
31
|
+
}
|
package/src/omo/generations.ts
CHANGED
|
@@ -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
|
|
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
|
|
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/src/store.ts
CHANGED
|
@@ -215,7 +215,7 @@ export interface WorkStartRequest extends WorkCommandRequest {
|
|
|
215
215
|
export type WorkReport = Pick<WorkUpdate, "sourceKey" | "kind" | "commandSeq" | "summary" | "details">;
|
|
216
216
|
|
|
217
217
|
export class WorkStoreError extends Error {
|
|
218
|
-
constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision", readonly ids: readonly string[] = []) {
|
|
218
|
+
constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision" | "invalid_replacement_details", readonly ids: readonly string[] = []) {
|
|
219
219
|
super(code);
|
|
220
220
|
}
|
|
221
221
|
}
|
|
@@ -420,8 +420,12 @@ export function replacementQuestionPayload(payload: Record<string, unknown>): Re
|
|
|
420
420
|
options: Array.isArray(withIds.options) ? withIds.options : [] };
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
/**
|
|
424
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Closed bindings whose question still takes an answer, delivered to the owner as a follow-up command. Bug 127:
|
|
425
|
+
* async_wait_closed (a waitForAnswer false question whose native wait ended) and recheck_incomplete (a recheck turn
|
|
426
|
+
* ended without a replacement; the question stands as asked). A permission is never resumed (submitDecision, info).
|
|
427
|
+
*/
|
|
428
|
+
export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", "async_wait_closed", "recheck_incomplete"]);
|
|
425
429
|
|
|
426
430
|
/** Checks structural completeness, not the semantic appropriateness of a historical answer. */
|
|
427
431
|
function completeDecisionAnswer(method: DecisionMethod, payload: unknown, response: unknown): boolean {
|
|
@@ -1374,7 +1378,14 @@ export class Store {
|
|
|
1374
1378
|
if (command && report.kind !== "observation" && (command.delivery === "pending" || command.delivery === "sending" || command.delivery === "unknown")) {
|
|
1375
1379
|
this.setWorkCommandDelivery(command.id, "accepted", { disposition: "work_report" });
|
|
1376
1380
|
}
|
|
1377
|
-
|
|
1381
|
+
// Bug 127: a question report on a recheck command must carry its replacement as an object; anything else would be
|
|
1382
|
+
// accepted without replacing and leave the old question recheck-only.
|
|
1383
|
+
const replacing = object(report.details) && typeof report.details.replaces_decision_id === "string";
|
|
1384
|
+
if (command && report.kind === "question" && !replacing && object(command.payload) &&
|
|
1385
|
+
(command.payload.decision_operation === "recheck" || command.payload.decision_operation === "request_permission")) {
|
|
1386
|
+
throw new WorkStoreError("invalid_replacement_details", [workId]);
|
|
1387
|
+
}
|
|
1388
|
+
if (command && report.kind === "question" && replacing) {
|
|
1378
1389
|
this.replaceDecisionFromReport({
|
|
1379
1390
|
scope: `work:${workId}`, sourceSessionPath: work.sessionPath,
|
|
1380
1391
|
reportId: report.sourceKey, commandId: command.id, details: report.details,
|
package/src/work.ts
CHANGED
|
@@ -85,6 +85,8 @@ const REPORT_RULE = "Report completed or failed with the highest command_seq of
|
|
|
85
85
|
const QUESTION_WITHOUT_DECISION = "No inbox question is open for this work. Ask the user with ask_user_question first " +
|
|
86
86
|
"(waitForAnswer true for a required confirmation, which then needs no kind=question report; " +
|
|
87
87
|
"waitForAnswer false only if the work may continue without the answer and you also report), then send kind=question again. A kind=question report alone creates no inbox question.";
|
|
88
|
+
const INVALID_REPLACEMENT_DETAILS = "This report answers a recheck, so details must be a JSON object, not a string: " +
|
|
89
|
+
"{replaces_decision_id, submission_id, and replacement:{method,payload,required} or replacement_decision_id}. Send the report again with details as an object.";
|
|
88
90
|
function envelope(command: WorkCommand): string {
|
|
89
91
|
return PREFIX + JSON.stringify({ version: 1, work_id: command.workId, command_id: command.id, command_seq: command.seq, kind: command.kind, payload: command.payload, report_rule: REPORT_RULE });
|
|
90
92
|
}
|
|
@@ -155,7 +157,9 @@ export class WorkRouter {
|
|
|
155
157
|
} catch (error) {
|
|
156
158
|
if (error instanceof RoutingError) return Response.json({ error: error.code }, { status: error.status });
|
|
157
159
|
if (error instanceof WorkStoreError) return Response.json({ error: error.code, ids: error.ids,
|
|
158
|
-
...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {})
|
|
160
|
+
...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {}),
|
|
161
|
+
...(error.code === "invalid_replacement_details" ? { detail: INVALID_REPLACEMENT_DETAILS } : {}) },
|
|
162
|
+
{ status: error.code === "invalid_replacement_details" ? 400 : 409 });
|
|
159
163
|
if (error instanceof DecisionStoreError) return Response.json({ error: error.code }, {
|
|
160
164
|
status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409,
|
|
161
165
|
});
|
|
@@ -601,7 +605,7 @@ export class WorkRouter {
|
|
|
601
605
|
} catch (error) {
|
|
602
606
|
// The live report call already showed the worker this refusal; recovery skips the entry.
|
|
603
607
|
// Unresolved commands retain their receipt state; completion needs a new valid report.
|
|
604
|
-
if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands"].includes(error.code)) continue;
|
|
608
|
+
if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands", "invalid_replacement_details"].includes(error.code)) continue;
|
|
605
609
|
if (!(error instanceof WorkStoreError) || error.code !== "required_decisions_pending") throw error;
|
|
606
610
|
deferred = Math.min(deferred, index);
|
|
607
611
|
}
|
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>
|
package/web/manual.d.ts
ADDED
|
@@ -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; }
|