zigmini 0.9.0-beta.3 → 1.0.0-beta.1

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.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. package/web/workstatus.js +36 -32
@@ -7,21 +7,28 @@ import { createSnapshot, pruneSnapshots } from "../runtime/generations.ts";
7
7
  import { activeRecord, readRegistry, type SlotRecord, type SlotRegistry, slotUrl, writeRegistry } from "../runtime/slots.ts";
8
8
  import { probe } from "./cli.ts";
9
9
  import { type BridgeGeneration, type HandoffDeps, type OverlapControl, runHandoff } from "./handoff.ts";
10
- import { installedVersion } from "./helper.ts";
10
+ import { followHomeLanguage, installedVersion } from "./helper.ts";
11
+ import { say, type Text } from "../server-text.ts";
11
12
  import { initialOverlap, observe, type OverlapState } from "../runtime/overlap-policy.ts";
12
13
  import { sampleResources } from "../runtime/resource-sample.ts";
13
14
  import { agentDir } from "../model-accounts.ts";
14
15
  import { attemptRefusal, readGuardState, readStoreSchema, storeIncompatibility } from "../engine/schema-guard.ts";
15
16
  import { supportedSchema } from "../engine/thread-schema.ts";
16
17
 
17
- const OVERLAP_REASONS: Readonly<Record<string, string>> = {
18
- critical_pressure: "메모리 압박 단계가 위험입니다",
19
- insufficient_available_memory: "쓸 수 있는 메모리가 2GiB보다 적습니다",
20
- sufficient_resources: "자원 충분: 최대 6시간",
21
- limited_resources: "자원 부족: 최대 1시간",
22
- resource_limits: "메모리 압박·두 세대 메모리 합·스왑 증가 기준을 3번 연속 넘었습니다",
18
+ const OVERLAP_REASONS: Readonly<Record<string, Text>> = {
19
+ critical_pressure: { en: "The memory pressure level is critical", ko: "메모리 압박 단계가 위험입니다" },
20
+ insufficient_available_memory: { en: "Less than 2 GiB of memory is available", ko: "쓸 수 있는 메모리가 2GiB보다 적습니다" },
21
+ sufficient_resources: { en: "Enough resources: up to 6 hours", ko: "자원 충분: 최대 6시간" },
22
+ limited_resources: { en: "Limited resources: up to 1 hour", ko: "자원 부족: 최대 1시간" },
23
+ resource_limits: { en: "The limits for memory pressure, combined memory of both generations, and swap growth were exceeded 3 times in a row", ko: "메모리 압박·두 세대 메모리 합·스왑 증가 기준을 3번 연속 넘었습니다" },
23
24
  };
24
25
 
26
+ const NOT_CONNECTED: Text = { en: "Could not connect to slot {slot}", ko: "슬롯 {slot}에 연결하지 못했습니다" };
27
+ const NO_TEMP_SESSION: Text = { en: "Could not open a temporary session", ko: "임시 세션을 열지 못했습니다" };
28
+ const STORE_AHEAD: Text = { en: "Store schema {store} > engine support {supported}", ko: "저장소 스키마 {store} > 엔진 지원 {supported}" };
29
+ const LAUNCHCTL_CODE: Text = { en: "launchctl exit code {code}", ko: "launchctl 종료 코드 {code}" };
30
+ const NO_GATEWAY_ANSWER: Text = { en: "No gateway response", ko: "게이트웨이 응답 없음" };
31
+
25
32
  /** Plan 5.9 with live macOS samples: the old generation's bridge tree is "active", the new one "new". */
26
33
  export function resourceOverlap(pids: () => Promise<{ readonly old: number | null; readonly fresh: number | null }>, log: (line: string) => void): OverlapControl {
27
34
  let state: OverlapState | null = null;
@@ -34,15 +41,15 @@ export function resourceOverlap(pids: () => Promise<{ readonly old: number | nul
34
41
  return {
35
42
  async start(now) {
36
43
  const initial = initialOverlap(await sample(), now);
37
- if (!initial.allowed) return { refuse: OVERLAP_REASONS[initial.reason]! };
44
+ if (!initial.allowed) return { refuse: say(OVERLAP_REASONS[initial.reason]!) };
38
45
  state = initial.state;
39
- return { deadline: state.deadline, reason: OVERLAP_REASONS[initial.reason]! };
46
+ return { deadline: state.deadline, reason: say(OVERLAP_REASONS[initial.reason]!) };
40
47
  },
41
48
  async check(now) {
42
49
  if (state === null) throw new Error("overlap_not_started");
43
50
  const next = observe(state, await sample(), now);
44
51
  state = next.state;
45
- return { deadline: state.deadline, reason: next.reason === null ? null : OVERLAP_REASONS[next.reason]! };
52
+ return { deadline: state.deadline, reason: next.reason === null ? null : say(OVERLAP_REASONS[next.reason]!) };
46
53
  },
47
54
  };
48
55
  }
@@ -74,9 +81,9 @@ export async function generationHealth(record: SlotRecord, token: string, sessio
74
81
  const timer = setTimeout(() => resolveStatus("disconnected"), timeoutMs);
75
82
  client.start();
76
83
  try {
77
- if (await readyOrFailed !== "ready") return `슬롯 ${record.slot}에 연결하지 못했습니다`;
84
+ if (await readyOrFailed !== "ready") return say(NOT_CONNECTED, { slot: record.slot });
78
85
  const opened = await client.command<{ sessionId?: unknown }>("open_session", { sessionPath, cwd, retain_on_disconnect: false, auto_title: false });
79
- if (typeof opened?.sessionId !== "string") return "임시 세션을 열지 못했습니다";
86
+ if (typeof opened?.sessionId !== "string") return say(NO_TEMP_SESSION);
80
87
  await client.command("close_session", { sessionId: opened.sessionId });
81
88
  return null;
82
89
  } catch (error) {
@@ -129,7 +136,7 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
129
136
  return null;
130
137
  }
131
138
  const found = storeIncompatibility(readStoreSchema(agentDir({ ...ctx.env, ...config })), supported);
132
- return found === null ? null : `저장소 스키마 ${found.store} > 엔진 지원 ${found.supported}`;
139
+ return found === null ? null : say(STORE_AHEAD, { store: found.store, supported: found.supported });
133
140
  },
134
141
  startRefusal: (record) => attemptRefusal(readGuardState(paths.run), { slot: record.slot, engine: record.engine, since: record.since }),
135
142
  startBridge: async (record) => {
@@ -145,7 +152,7 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
145
152
  if (agentState(launchd, ctx, spec.label).loaded) launchd.launchctl(["bootout", `${domain}/${spec.label}`]);
146
153
  launchd.writeText(path, plistXml(spec));
147
154
  const booted = await bootstrap(launchd, ctx, path);
148
- return booted.code === 0 ? null : booted.stderr.trim() || `launchctl 종료 코드 ${booted.code}`;
155
+ return booted.code === 0 ? null : booted.stderr.trim() || say(LAUNCHCTL_CODE, { code: booted.code });
149
156
  },
150
157
  stopBridge: async (slot) => {
151
158
  const label = slotLabel(paths.launchdPrefix, slot);
@@ -156,7 +163,7 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
156
163
  healthCheck: (record) => generationHealth(record, token, paths.sessions, ctx.userHome),
157
164
  restartGateway: async () => {
158
165
  const kicked = launchd.launchctl(["kickstart", "-k", `${domain}/${agentLabel(paths.launchdPrefix, "gateway")}`]);
159
- return kicked.code === 0 ? null : kicked.stderr.trim() || `launchctl 종료 코드 ${kicked.code}`;
166
+ return kicked.code === 0 ? null : kicked.stderr.trim() || say(LAUNCHCTL_CODE, { code: kicked.code });
160
167
  },
161
168
  gatewayReady: async () => {
162
169
  const answer = await probe(paths.ports.web);
@@ -164,7 +171,7 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
164
171
  },
165
172
  reload: async () => {
166
173
  const answer = await postJson(`${web}/internal/runtime`, token, { action: "reload" });
167
- return answer?.status === 200 ? null : String(answer?.body.error ?? (answer === null ? "게이트웨이 응답 없음" : `HTTP ${answer.status}`));
174
+ return answer?.status === 200 ? null : String(answer?.body.error ?? (answer === null ? say(NO_GATEWAY_ANSWER) : `HTTP ${answer.status}`));
168
175
  },
169
176
  overlap: options.overlap,
170
177
  cleanup: async (keepSnapshots, keepEngines) => {
@@ -185,6 +192,7 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
185
192
 
186
193
  /** `zigmini _update-handoff <request id>`: stdout gets only the PROMOTED / FAILED line; the log goes to stderr (updater.log). */
187
194
  export async function handoffCommand(launchd: LaunchdDeps, ctx: LaunchdContext, argv: readonly string[]): Promise<number> {
195
+ followHomeLanguage(ctx.paths.home);
188
196
  const log = (line: string) => process.stderr.write(`${new Date().toISOString()} handoff ${argv[0] ?? ""} ${line}\n`);
189
197
  const out = (line: string) => process.stdout.write(`${line}\n`);
190
198
  const legacy: SlotRecord = { slot: "a", state: "active", port: ctx.paths.ports.bridge, version: "unknown", snapshot: null, engine: null, since: "" };
@@ -1,5 +1,6 @@
1
1
  import type { RuntimeSlot } from "../runtime/paths.ts";
2
2
  import { activeRecord, otherSlot, type SlotRecord, type SlotRegistry } from "../runtime/slots.ts";
3
+ import { say, type Text } from "../server-text.ts";
3
4
  import { HANDOFF_FAILED, HANDOFF_PROMOTED } from "./strategy.ts";
4
5
 
5
6
  /**
@@ -63,7 +64,17 @@ export const POLL_MS = 1000;
63
64
 
64
65
  export type HandoffResult = "promoted" | "untouched_failure" | "failure";
65
66
 
66
- const NOT_UP = `${BRIDGE_START_WAIT_MS / 1000}초 안에 엔진이 뜨지 않았습니다`;
67
+ const NOT_UP: Text = { en: "The engine did not start within {seconds} seconds", ko: "{seconds}초 안에 엔진이 뜨지 않았습니다" };
68
+ const STALE_GENERATION: Text = { en: "The previous update has not finished cleaning up its old generation yet", ko: "이전 업데이트의 이전 세대 정리가 아직 끝나지 않았습니다" };
69
+ const NO_RESOURCES: Text = { en: "Not enough resources, so the new generation was not started: {reason}", ko: "자원이 부족해 새 세대를 띄우지 않았습니다: {reason}" };
70
+ const PREFLIGHT_BLOCKED: Text = { en: "Did not start the new generation (slot {slot}, engine {engine}): {reason}", ko: "새 세대(슬롯 {slot}, 엔진 {engine})를 시작하지 않았습니다: {reason}" };
71
+ const START_FAILED: Text = { en: "Could not start the new generation (slot {slot}): {reason}", ko: "새 세대(슬롯 {slot})를 시작하지 못했습니다: {reason}" };
72
+ const CHECK_FAILED: Text = { en: "Check of the new generation (slot {slot}) failed: {reason}", ko: "새 세대(슬롯 {slot}) 확인 실패: {reason}" };
73
+ const GATEWAY_NOT_READY: Text = { en: "The gateway was not ready on the new version{detail}", ko: "게이트웨이가 새 버전으로 준비되지 않았습니다{detail}" };
74
+ const SWITCH_FAILED: Text = { en: "The gateway could not switch to the new generation: {reason}", ko: "게이트웨이가 새 세대로 바꾸지 못했습니다: {reason}" };
75
+ const DEADLINE_MOVED: Text = { en: "The update wrap-up deadline was moved up to {deadline}: {reason}", ko: "업데이트 마무리 마감이 {deadline}로 당겨졌습니다: {reason}" };
76
+ const WRAP_UP_FORCED: Text = { en: "Update {version} wrapped up: the old generation ended the sessions that were not finished by the deadline and exited.", ko: "업데이트 {version} 마무리: 이전 세대가 마감까지 끝나지 않은 세션을 정리하고 종료했습니다." };
77
+ const WRAP_UP_CLEAN: Text = { en: "Update {version} wrapped up: all sessions moved to the new generation and the old generation exited.", ko: "업데이트 {version} 마무리: 모든 세션이 새 세대로 옮겨졌고 이전 세대를 종료했습니다." };
67
78
 
68
79
  function legacyRegistry(port: number, version: string, now: number): SlotRegistry {
69
80
  return { schema: 1, slots: [{ slot: "a", state: "active", port, version, snapshot: null, engine: null, since: new Date(now).toISOString() }] };
@@ -97,14 +108,14 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
97
108
  const old = activeRecord(listed);
98
109
  // A slot left draining or starting by an interrupted handoff (a reboot ends the helper) is removed once its OMO is gone.
99
110
  for (const stale of listed.slots.filter((record) => record.state !== "active")) {
100
- if ((await deps.generation(stale, "status"))?.omo === "running") return fail(true, "이전 업데이트의 이전 세대 정리가 아직 끝나지 않았습니다");
111
+ if ((await deps.generation(stale, "status"))?.omo === "running") return fail(true, say(STALE_GENERATION));
101
112
  deps.log(`끝나지 않은 이전 전환의 슬롯 ${stale.slot}(${stale.state})을 정리합니다`);
102
113
  await deps.stopBridge(stale.slot);
103
114
  stored = { schema: 1, slots: [old] };
104
115
  deps.writeRegistry(stored);
105
116
  }
106
117
  const start = await deps.overlap.start(deps.now());
107
- if ("refuse" in start) return fail(true, `자원이 부족해 새 세대를 띄우지 않았습니다: ${start.refuse}`);
118
+ if ("refuse" in start) return fail(true, say(NO_RESOURCES, { reason: start.refuse }));
108
119
  deps.log(`최대 겹침 마감 ${new Date(start.deadline).toISOString()} (${start.reason})`);
109
120
 
110
121
  const slot = otherSlot(old.slot);
@@ -116,27 +127,27 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
116
127
  deps.writeRegistry(stored);
117
128
  };
118
129
  const blocked = deps.preflight(fresh);
119
- if (blocked !== null) return fail(true, `새 세대(슬롯 ${slot}, 엔진 ${fresh.engine ?? "current"})를 시작하지 않았습니다: ${blocked}`);
130
+ if (blocked !== null) return fail(true, say(PREFLIGHT_BLOCKED, { slot, engine: fresh.engine ?? "current", reason: blocked }));
120
131
  deps.writeRegistry({ schema: 1, slots: [old, fresh] });
121
132
  const started = await deps.startBridge(fresh);
122
133
  if (started !== null) {
123
134
  await undo();
124
- return fail(true, `새 세대(슬롯 ${slot})를 시작하지 못했습니다: ${started}`);
135
+ return fail(true, say(START_FAILED, { slot, reason: started }));
125
136
  }
126
137
  let refused: string | null = null;
127
138
  const up = await until(deps, BRIDGE_START_WAIT_MS, async () => (refused = deps.startRefusal(fresh)) !== null ||
128
139
  (await deps.generation(fresh, "status"))?.omo === "running");
129
- const unhealthy = refused ?? (up ? await deps.healthCheck(fresh) : NOT_UP);
140
+ const unhealthy = refused ?? (up ? await deps.healthCheck(fresh) : say(NOT_UP, { seconds: BRIDGE_START_WAIT_MS / 1000 }));
130
141
  if (unhealthy !== null) {
131
142
  await undo();
132
- return fail(true, `새 세대(슬롯 ${slot}) 확인 실패: ${unhealthy}`);
143
+ return fail(true, say(CHECK_FAILED, { slot, reason: unhealthy }));
133
144
  }
134
145
  deps.log(`새 세대 슬롯 ${slot} 준비됨 (${snap.name}, 엔진 ${fresh.engine ?? "current"})`);
135
146
 
136
147
  const restarted = await deps.restartGateway();
137
148
  if (restarted !== null || !await until(deps, GATEWAY_WAIT_MS, deps.gatewayReady)) {
138
149
  await undo();
139
- return fail(false, `게이트웨이가 새 버전으로 준비되지 않았습니다${restarted === null ? "" : `: ${restarted}`}`);
150
+ return fail(false, say(GATEWAY_NOT_READY, { detail: restarted === null ? "" : `: ${restarted}` }));
140
151
  }
141
152
  const draining: SlotRecord = { ...old, state: "draining" };
142
153
  const active: SlotRecord = { ...fresh, state: "active" };
@@ -147,7 +158,7 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
147
158
  deps.writeRegistry({ schema: 1, slots: [old, fresh] });
148
159
  await deps.reload();
149
160
  await undo();
150
- return fail(false, `게이트웨이가 새 세대로 바꾸지 못했습니다: ${reloadError}`);
161
+ return fail(false, say(SWITCH_FAILED, { reason: reloadError }));
151
162
  }
152
163
  deps.log(`승격: 슬롯 ${slot}이 활성, 슬롯 ${old.slot}은 배수`);
153
164
  deps.signal(HANDOFF_PROMOTED);
@@ -159,7 +170,7 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
159
170
  if ((await deps.generation(draining, "status"))?.omo === "exited") break;
160
171
  const now = deps.now();
161
172
  const overlap = await deps.overlap.check(now);
162
- if (overlap.reason !== null) await deps.notify(`업데이트 마무리 마감이 ${new Date(overlap.deadline).toISOString()}로 당겨졌습니다: ${overlap.reason}`);
173
+ if (overlap.reason !== null) await deps.notify(say(DEADLINE_MOVED, { deadline: new Date(overlap.deadline).toISOString(), reason: overlap.reason }));
163
174
  if (now >= overlap.deadline) {
164
175
  terminated = true;
165
176
  deps.log(`최대 겹침 마감이 지나 슬롯 ${old.slot}을 끝냅니다`);
@@ -175,8 +186,8 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
175
186
  const keep = new Set([snap.name, snapshotName(old.snapshot)].filter((name): name is string => name !== null));
176
187
  await deps.cleanup(keep, new Set([fresh.engine].filter((name): name is string => name !== null)));
177
188
  await deps.notify(terminated
178
- ? `업데이트 ${deps.version} 마무리: 이전 세대가 마감까지 끝나지 않은 세션을 정리하고 종료했습니다.`
179
- : `업데이트 ${deps.version} 마무리: 모든 세션이 새 세대로 옮겨졌고 이전 세대를 종료했습니다.`);
189
+ ? say(WRAP_UP_FORCED, { version: deps.version })
190
+ : say(WRAP_UP_CLEAN, { version: deps.version }));
180
191
  deps.log("끝");
181
192
  return "promoted";
182
193
  }
@@ -2,6 +2,8 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, read
2
2
  import { join } from "node:path";
3
3
  import { PREV_FILE, promotePending, restorePrev } from "../consent/consent.ts";
4
4
  import { type Phase, PREV_ENGINE_FILE, type UpdateRequest, type UpdateResult, updateDir, writeState } from "./state.ts";
5
+ import { say, setServerLanguage, textIn, type Text } from "../server-text.ts";
6
+ import { readUiLanguage } from "../ui-language.ts";
5
7
  import type { RestartTarget, UntouchedFailure, UpdateStrategy } from "./strategy.ts";
6
8
 
7
9
  /**
@@ -16,8 +18,40 @@ import type { RestartTarget, UntouchedFailure, UpdateStrategy } from "./strategy
16
18
  /** The same 600 s as `zigmini start` (bug 104): reconnect recovery can keep a correct new version from being ready for minutes. */
17
19
  export const READY_WAIT_MS = 600_000;
18
20
  export const STOP_WAIT_MS = 15_000;
19
- export const LOST_RECORDS = "업데이트 전 백업 뒤에 생긴 기록(대화 등)은 없어졌을 수 있습니다.";
20
- export const UPDATE_AGAIN = "옛 엔진이 공유 폴더 스키마를 읽지 못해 브리지가 멈췄습니다. 다시 업데이트하세요(새 엔진 필요).";
21
+ export const LOST_RECORDS: Text = { en: "Records made after the pre-update backup (conversations and so on) may be lost.", ko: "업데이트 전 백업 뒤에 생긴 기록(대화 등)은 없어졌을 수 있습니다." };
22
+ export const UPDATE_AGAIN: Text = { en: "The old engine could not read the shared folder schema, so the bridge stopped. Update again (a new engine is required).", ko: "옛 엔진이 공유 폴더 스키마를 읽지 못해 브리지가 멈췄습니다. 다시 업데이트하세요(새 엔진 필요)." };
23
+
24
+ const INSTALL_NEW: Text = { en: "Installing the new version", ko: "새 버전 설치" };
25
+ const INSTALL_ENGINE: Text = { en: "Installing the new engine", ko: "새 엔진 설치" };
26
+ const INSTALL_PREV: Text = { en: "Reinstalling the previous version", ko: "이전 버전 설치" };
27
+ const STEP_FAILED: Text = { en: "{what} failed: {detail}", ko: "{what} 실패: {detail}" };
28
+ const SAVE_FAILED: Text = { en: "Saving the current version failed: {detail}", ko: "지금 버전 저장 실패: {detail}" };
29
+ const NOT_STARTED: Text = { en: "Could not start the update (nothing changed): {error}", ko: "업데이트를 시작하지 못했습니다(바뀐 것 없음): {error}" };
30
+ const WRONG_VERSION: Text = { en: "The installed version is {installed} (expected {expected})", ko: "설치된 버전이 {installed}입니다(기대 {expected})" };
31
+ const UNKNOWN: Text = { en: "unknown", ko: "알 수 없음" };
32
+ const NO_CONSENT: Text = { en: "There is no consent record for the new permission notice", ko: "새 권한 안내에 대한 동의 기록이 없습니다" };
33
+ const NOT_READY: Text = { en: "The new version was not ready within {seconds} seconds (health {detail})", ko: "새 버전이 {seconds}초 안에 준비되지 않았습니다(health {detail})" };
34
+ const NO_ANSWER: Text = { en: "no response", ko: "응답 없음" };
35
+ const HEALTH_DETAIL: Text = { en: "omo={omo}, version {version}", ko: "omo={omo}, 버전 {version}" };
36
+ const UPDATED: Text = { en: "Updated to {to}", ko: "{to}으로 업데이트했습니다" };
37
+ const UPDATE_FAILED: Text = { en: "Update failed: {failure}.", ko: "업데이트에 실패했습니다: {failure}." };
38
+ const NOT_RESTARTED: Text = { en: "The running gateway and bridge were not restarted, and the installation was rolled back to {from}.", ko: "실행 중인 게이트웨이와 브리지는 다시 시작하지 않았고, 설치를 {from}으로 되돌렸습니다." };
39
+ const ROLLED_BACK: Text = { en: "Rolled back to {from}. {lost}", ko: "{from}으로 되돌렸습니다. {lost}" };
40
+ const ROLLBACK_PARTLY_FAILED: Text = { en: "Rollback also partly failed: {problems}. Check the log and run `zigmini doctor`.", ko: "되돌리기도 일부 실패했습니다: {problems}. 로그를 확인하고 `zigmini doctor`를 실행하세요." };
41
+ const OLD_WEB_DOWN: Text = { en: "The web of the previous version {from} does not respond", ko: "이전 버전 {from}의 웹이 응답하지 않습니다" };
42
+ const STEP_STOP: Text = { en: "Stop", ko: "정지" };
43
+ const STEP_RESTORE_DB: Text = { en: "Restore the DB", ko: "DB 복원" };
44
+ const STEP_RESTORE_LINK: Text = { en: "Restore the engine link", ko: "엔진 링크 복원" };
45
+ const STEP_RESTORE_CONSENT: Text = { en: "Restore the consent", ko: "동의 복원" };
46
+ const STEP_START: Text = { en: "Start", ko: "시작" };
47
+
48
+ /**
49
+ * The helper and the handoff command are separate processes from the gateway, so their texts follow the language saved
50
+ * in the install home: the file is read each time a text is made, like the gateway's own source.
51
+ */
52
+ export function followHomeLanguage(home: string): void {
53
+ setServerLanguage(() => readUiLanguage(home));
54
+ }
21
55
 
22
56
  /** The installer's refusal code (src/engine/install.ts ENGINE_SCHEMA_INCOMPATIBLE) as printed by `setup --engine-only`. */
23
57
  export const ENGINE_SCHEMA_REFUSAL = "engine_schema_incompatible";
@@ -98,6 +132,7 @@ export async function waitFor(deps: HelperDeps, ok: (probe: Probe | null) => boo
98
132
  }
99
133
 
100
134
  export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promise<UpdateResult> {
135
+ followHomeLanguage(deps.home);
101
136
  const dir = updateDir(deps.home);
102
137
  mkdirSync(dir, { recursive: true });
103
138
  const logPath = join(dir, `update-${request.id}.log`);
@@ -107,10 +142,10 @@ export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promi
107
142
  writeState(deps.home, { id: request.id, from: request.from, to: request.to, phase: name, at: new Date(deps.now()).toISOString(), result: null });
108
143
  };
109
144
  const env = { BUN_INSTALL: deps.bunInstall };
110
- const bun = async (args: readonly string[], what: string) => {
145
+ const bun = async (args: readonly string[], what: Text) => {
111
146
  const out = await deps.run([deps.bunPath, ...args], dir, env);
112
147
  log(`$ bun ${args.join(" ")} -> ${out.code}`);
113
- if (out.code !== 0) throw new StepError(`${what} 실패: ${tail(out.stderr || out.stdout)}`);
148
+ if (out.code !== 0) throw new StepError(say(STEP_FAILED, { what: say(what), detail: tail(out.stderr || out.stdout) }));
114
149
  };
115
150
  const prevTgz = join(dir, `prev-${request.from}.tgz`);
116
151
  const finish = (result: Omit<UpdateResult, "from" | "to" | "logPath" | "finishedAt">): UpdateResult => {
@@ -127,27 +162,27 @@ export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promi
127
162
  await deps.run([deps.bunPath, "pm", "pack", "--filename", prevTgz, "--ignore-scripts", "--quiet"], deps.packageRoot, env)
128
163
  .then((out) => {
129
164
  log(`$ bun pm pack -> ${out.code}`);
130
- if (out.code !== 0 || !existsSync(prevTgz)) throw new StepError(`지금 버전 저장 실패: ${tail(out.stderr || out.stdout)}`);
165
+ if (out.code !== 0 || !existsSync(prevTgz)) throw new StepError(say(SAVE_FAILED, { detail: tail(out.stderr || out.stdout) }));
131
166
  });
132
167
  writeFileSync(join(dir, PREV_ENGINE_FILE), `${engineLink(deps.omoCurrent) ?? ""}\n`);
133
168
  } catch (error) {
134
- return finish({ ok: false, rolledBack: false, omo: null, message: `업데이트를 시작하지 못했습니다(바뀐 것 없음): ${(error as Error).message}` });
169
+ return finish({ ok: false, rolledBack: false, omo: null, message: say(NOT_STARTED, { error: (error as Error).message }) });
135
170
  }
136
171
 
137
172
  let failure: string | null = null;
138
173
  try {
139
174
  phase("install");
140
- await bun(["add", "-g", request.source, ...(deps.registry === null ? [] : ["--registry", deps.registry])], "새 버전 설치");
175
+ await bun(["add", "-g", request.source, ...(deps.registry === null ? [] : ["--registry", deps.registry])], INSTALL_NEW);
141
176
  const installed = installedVersion(deps.packageRoot);
142
- if (installed !== request.to) throw new StepError(`설치된 버전이 ${installed ?? "알 수 없음"}입니다(기대 ${request.to})`);
177
+ if (installed !== request.to) throw new StepError(say(WRONG_VERSION, { installed: installed ?? say(UNKNOWN), expected: request.to }));
143
178
  if (request.noticeChanged) {
144
179
  phase("consent");
145
- if (!promotePending(deps.home)) throw new StepError("새 권한 안내에 대한 동의 기록이 없습니다");
180
+ if (!promotePending(deps.home)) throw new StepError(say(NO_CONSENT));
146
181
  }
147
182
  if (request.engineChanged) {
148
183
  phase("engine");
149
184
  try {
150
- await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], "새 엔진 설치");
185
+ await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], INSTALL_ENGINE);
151
186
  } catch (error) {
152
187
  // Findings 214-217: the installer refused before selecting the engine and nothing running was restarted.
153
188
  if (!(error instanceof StepError) || !error.message.includes(ENGINE_SCHEMA_REFUSAL)) throw error;
@@ -165,10 +200,11 @@ export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promi
165
200
  phase("health");
166
201
  const probe = await waitFor(deps, (p) => p?.omo === "ready" && p.version === request.to, deps.readyWaitMs ?? READY_WAIT_MS);
167
202
  if (probe?.omo !== "ready" || probe.version !== request.to) {
168
- throw new StepError(`새 버전이 ${(deps.readyWaitMs ?? READY_WAIT_MS) / 1000}초 안에 준비되지 않았습니다(health ${probe === null ? "응답 없음" : `omo=${probe.omo}, 버전 ${probe.version}`})`);
203
+ throw new StepError(say(NOT_READY, { seconds: (deps.readyWaitMs ?? READY_WAIT_MS) / 1000,
204
+ detail: probe === null ? say(NO_ANSWER) : say(HEALTH_DETAIL, { omo: String(probe.omo), version: String(probe.version) }) }));
169
205
  }
170
206
  rmSync(join(deps.home, PREV_FILE), { force: true });
171
- const done = finish({ ok: true, rolledBack: false, omo: probe.omo, message: `${request.to}으로 업데이트했습니다` });
207
+ const done = finish({ ok: true, rolledBack: false, omo: probe.omo, message: say(UPDATED, { to: request.to }) });
172
208
  await deps.strategy.settle?.().catch((error: unknown) => log(`업데이트 뒤 정리 실패: ${error instanceof Error ? error.message : String(error)}`));
173
209
  return done;
174
210
  } catch (error) {
@@ -184,29 +220,29 @@ async function untouchedRollback(request: UpdateRequest, deps: HelperDeps, ctx:
184
220
  readonly prevTgz: string;
185
221
  readonly log: (line: string) => void;
186
222
  readonly phase: (name: Phase) => void;
187
- readonly bun: (args: readonly string[], what: string) => Promise<void>;
223
+ readonly bun: (args: readonly string[], what: Text) => Promise<void>;
188
224
  readonly finish: (result: Omit<UpdateResult, "from" | "to" | "logPath" | "finishedAt">) => UpdateResult;
189
225
  }): Promise<UpdateResult> {
190
226
  ctx.phase("rollback");
191
227
  const problems: string[] = [];
192
- const attempt = async (what: string, step: () => unknown) => {
228
+ const attempt = async (what: Text, step: () => unknown) => {
193
229
  try {
194
230
  await step();
195
231
  } catch (error) {
196
- problems.push(`${what}: ${error instanceof Error ? error.message : String(error)}`);
197
- ctx.log(`되돌리기 ${what} 실패: ${problems.at(-1)}`);
232
+ problems.push(`${say(what)}: ${error instanceof Error ? error.message : String(error)}`);
233
+ ctx.log(`되돌리기 ${textIn("ko", what)} 실패: ${problems.at(-1)}`);
198
234
  }
199
235
  };
200
- await attempt("이전 버전 설치", () => ctx.bun(["add", "-g", ctx.prevTgz], "이전 버전 설치"));
201
- await attempt("엔진 링크 복원", () => {
236
+ await attempt(INSTALL_PREV, () => ctx.bun(["add", "-g", ctx.prevTgz], INSTALL_PREV));
237
+ await attempt(STEP_RESTORE_LINK, () => {
202
238
  const prev = readFileSync(join(updateDir(deps.home), PREV_ENGINE_FILE), "utf8").trim();
203
239
  if (prev !== "" && engineLink(deps.omoCurrent) !== prev) setEngineLink(deps.omoCurrent, prev);
204
240
  });
205
- await attempt("동의 복원", () => restorePrev(deps.home));
241
+ await attempt(STEP_RESTORE_CONSENT, () => restorePrev(deps.home));
206
242
  const probe = await deps.probe();
207
- const message = [`업데이트에 실패했습니다: ${ctx.failure.message}.`,
208
- problems.length === 0 ? `실행 중인 게이트웨이와 브리지는 다시 시작하지 않았고, 설치를 ${request.from}으로 되돌렸습니다.`
209
- : `되돌리기도 일부 실패했습니다: ${problems.join("; ")}. 로그를 확인하고 \`zigmini doctor\`를 실행하세요.`].join(" ");
243
+ const message = [say(UPDATE_FAILED, { failure: ctx.failure.message }),
244
+ problems.length === 0 ? say(NOT_RESTARTED, { from: request.from })
245
+ : say(ROLLBACK_PARTLY_FAILED, { problems: problems.join("; ") })].join(" ");
210
246
  return ctx.finish({ ok: false, rolledBack: problems.length === 0, omo: probe?.omo ?? null, message });
211
247
  }
212
248
 
@@ -215,32 +251,32 @@ async function rollback(request: UpdateRequest, deps: HelperDeps, ctx: {
215
251
  readonly prevTgz: string;
216
252
  readonly log: (line: string) => void;
217
253
  readonly phase: (name: Phase) => void;
218
- readonly bun: (args: readonly string[], what: string) => Promise<void>;
254
+ readonly bun: (args: readonly string[], what: Text) => Promise<void>;
219
255
  readonly finish: (result: Omit<UpdateResult, "from" | "to" | "logPath" | "finishedAt">) => UpdateResult;
220
256
  }): Promise<UpdateResult> {
221
257
  ctx.phase("rollback");
222
258
  const problems: string[] = [];
223
- const attempt = async (what: string, step: () => unknown) => {
259
+ const attempt = async (what: Text, step: () => unknown) => {
224
260
  try {
225
261
  await step();
226
262
  } catch (error) {
227
- problems.push(`${what}: ${error instanceof Error ? error.message : String(error)}`);
228
- ctx.log(`되돌리기 ${what} 실패: ${problems.at(-1)}`);
263
+ problems.push(`${say(what)}: ${error instanceof Error ? error.message : String(error)}`);
264
+ ctx.log(`되돌리기 ${textIn("ko", what)} 실패: ${problems.at(-1)}`);
229
265
  }
230
266
  };
231
- await attempt("정지", async () => {
267
+ await attempt(STEP_STOP, async () => {
232
268
  await deps.stop("gateway");
233
269
  await deps.stop("bridge");
234
270
  await waitFor(deps, (probe) => probe === null, STOP_WAIT_MS);
235
271
  });
236
- await attempt("DB 복원", () => restoreDatabase(request.backup, deps.dataDir));
237
- await attempt("이전 버전 설치", () => ctx.bun(["add", "-g", ctx.prevTgz], "이전 버전 설치"));
238
- await attempt("엔진 링크 복원", () => {
272
+ await attempt(STEP_RESTORE_DB, () => restoreDatabase(request.backup, deps.dataDir));
273
+ await attempt(INSTALL_PREV, () => ctx.bun(["add", "-g", ctx.prevTgz], INSTALL_PREV));
274
+ await attempt(STEP_RESTORE_LINK, () => {
239
275
  const prev = readFileSync(join(updateDir(deps.home), PREV_ENGINE_FILE), "utf8").trim();
240
276
  if (prev !== "" && engineLink(deps.omoCurrent) !== prev) setEngineLink(deps.omoCurrent, prev);
241
277
  });
242
- await attempt("동의 복원", () => restorePrev(deps.home));
243
- await attempt("시작", async () => {
278
+ await attempt(STEP_RESTORE_CONSENT, () => restorePrev(deps.home));
279
+ await attempt(STEP_START, async () => {
244
280
  for (const target of ["bridge", "gateway"] as const) {
245
281
  const error = await deps.start(target);
246
282
  if (error !== null) throw new Error(`${target}: ${error}`);
@@ -249,10 +285,10 @@ async function rollback(request: UpdateRequest, deps: HelperDeps, ctx: {
249
285
  const settled = (p: Probe | null) => p?.status === 200 && p.version === request.from && (p.omo === "ready" || p.omo === "schema_too_new");
250
286
  const probe = await waitFor(deps, settled, deps.readyWaitMs ?? READY_WAIT_MS);
251
287
  const back = probe?.status === 200 && probe.version === request.from;
252
- if (!back) problems.push(`이전 버전 ${request.from}의 웹이 응답하지 않습니다`);
288
+ if (!back) problems.push(say(OLD_WEB_DOWN, { from: request.from }));
253
289
  const omo = probe?.omo ?? null;
254
- const message = [`업데이트에 실패했습니다: ${ctx.failure}.`,
255
- problems.length === 0 ? `${request.from}으로 되돌렸습니다. ${LOST_RECORDS}` : `되돌리기도 일부 실패했습니다: ${problems.join("; ")}. 로그를 확인하고 \`zigmini doctor\`를 실행하세요.`,
256
- ...(omo === "schema_too_new" ? [UPDATE_AGAIN] : [])].join(" ");
290
+ const message = [say(UPDATE_FAILED, { failure: ctx.failure }),
291
+ problems.length === 0 ? say(ROLLED_BACK, { from: request.from, lost: say(LOST_RECORDS) }) : say(ROLLBACK_PARTLY_FAILED, { problems: problems.join("; ") }),
292
+ ...(omo === "schema_too_new" ? [say(UPDATE_AGAIN)] : [])].join(" ");
257
293
  return ctx.finish({ ok: false, rolledBack: problems.length === 0, omo, message });
258
294
  }
@@ -6,6 +6,7 @@ import type { InstallKind } from "../runtime/install-kind.ts";
6
6
  import { type Origin, REQUEST_FILE, type UpdateRequest, readState, updateDir, updateRunning, writeJson, writeState } from "./state.ts";
7
7
  import { TargetError, type TargetInfo } from "./target.ts";
8
8
  import { engineBuildId } from "../engine/build-id.ts";
9
+ import { say, type Text } from "../server-text.ts";
9
10
 
10
11
  /**
11
12
  * POST /api/update and POST /api/update/consent in the gateway (plan Todo 23). The pre-check refuses with 409 and a
@@ -13,13 +14,28 @@ import { engineBuildId } from "../engine/build-id.ts";
13
14
  * connection, writes request.json and starts the one-shot helper job.
14
15
  */
15
16
 
16
- export const BUSY_MESSAGE = "진행 중인 턴이 끊깁니다";
17
- export const CONSENT_ON_MAC_MESSAGE = "맥에서 새 권한 안내에 동의해야 합니다";
18
- export const ROLLBACK_SCHEMA_WARNING = "되돌리면 옛 엔진이 공유 폴더를 읽지 못할 수 있습니다";
19
- export const UNKNOWN_SCHEMA_LINE = "새 엔진이 공유 스키마를 몇으로 올리는지 알 수 없습니다.";
20
- export const CHECKOUT_MESSAGE = "이 설치(checkout)는 저장소에서 배포합니다. 업데이트는 npm 설치에서만 씁니다";
21
-
22
- export const DRAINING_MESSAGE = "이전 업데이트가 아직 이전 세대를 정리하고 있습니다. 정리가 끝난 뒤 다시 하세요";
17
+ export const BUSY_MESSAGE: Text = { en: "Running turns will be cut off", ko: "진행 중인 턴이 끊깁니다" };
18
+ export const CONSENT_ON_MAC_MESSAGE: Text = { en: "You must agree to the new permission notice on the Mac", ko: "맥에서 새 권한 안내에 동의해야 합니다" };
19
+ export const ROLLBACK_SCHEMA_WARNING: Text = { en: "If you roll back, the old engine may not be able to read the shared folder", ko: "되돌리면 옛 엔진이 공유 폴더를 읽지 못할 수 있습니다" };
20
+ export const UNKNOWN_SCHEMA_LINE: Text = { en: "Cannot tell which shared schema version the new engine upgrades to.", ko: "새 엔진이 공유 스키마를 몇으로 올리는지 알 수 없습니다." };
21
+ export const CHECKOUT_MESSAGE: Text = { en: "This installation (checkout) is deployed from the repository. Updates only work for npm installations", ko: "이 설치(checkout)는 저장소에서 배포합니다. 업데이트는 npm 설치에서만 씁니다" };
22
+
23
+ export const DRAINING_MESSAGE: Text = { en: "The previous update is still cleaning up the old generation. Try again after the cleanup finishes", ko: "이전 업데이트가 아직 이전 세대를 정리하고 있습니다. 정리가 끝난 뒤 다시 하세요" };
24
+
25
+ const PENDING_QUESTIONS: Text = { en: "{count} question(s) are waiting for an answer. Answer them in the Inbox first. You can update after that.", ko: "답을 기다리는 질문이 {count}개 있습니다. 확인함에서 먼저 답해 주세요. 답한 뒤 업데이트할 수 있습니다." };
26
+ const SCHEMA_UNREADABLE: Text = { en: "Engine {omo} of {version} (supports {supported}) cannot read the shared folder schema {store}, so the update was not applied. A version that supports schema {store} is required", ko: "공유 폴더 스키마 {store}를 {version}의 엔진 {omo}(지원 {supported})이 읽지 못해 업데이트하지 않았습니다. 스키마 {store}를 지원하는 버전이 필요합니다" };
27
+ const STORE_SCHEMA_LINE: Text = { en: "Store schema {store}", ko: "저장소 스키마 {store}" };
28
+ const TARGET_SUPPORT_LINE: Text = { en: "Target engine supports {supported}", ko: "대상 엔진 지원 {supported}" };
29
+ const IN_PROGRESS: Text = { en: "An update is already in progress", ko: "업데이트가 이미 진행 중입니다" };
30
+ const SAME_VERSION: Text = { en: "Already on {version}", ko: "이미 {version}입니다" };
31
+ const CONSENT_REQUIRED: Text = { en: "You must agree to the new permission notice (notice version {version}) before updating", ko: "새 권한 안내(안내문 버전 {version})에 동의해야 업데이트합니다" };
32
+ const BAD_UPDATE_BODY: Text = { en: "The body must be { version?, force?, confirmSchema? }", ko: "본문은 { version?, force?, confirmSchema? } 입니다" };
33
+ const TARGET_CHECK_FAILED: Text = { en: "Could not check the target version: {error}", ko: "대상 버전을 확인하지 못했습니다: {error}" };
34
+ const BACKUP_FAILED: Text = { en: "DB backup failed: {error}", ko: "DB 백업 실패: {error}" };
35
+ const HELPER_START_FAILED: Text = { en: "Could not start the update helper: {error}", ko: "업데이트 도우미를 시작하지 못했습니다: {error}" };
36
+ const BAD_CONSENT_BODY: Text = { en: "The body must be { version, noticeVersion, answer }", ko: "본문은 { version, noticeVersion, answer } 입니다" };
37
+ const DECLINED: Text = { en: "Update cancelled because you did not agree", ko: "동의하지 않아 업데이트하지 않습니다" };
38
+ const NOTICE_CHANGED: Text = { en: "The permission notice changed in the meantime. Check it again", ko: "권한 안내문이 그사이 바뀌었습니다. 다시 확인하세요" };
23
39
 
24
40
  export type RefusalCode = "not_updatable" | "in_progress" | "same_version" | "busy" | "consent_on_mac" | "consent_required" | "schema_confirm" | "pending_questions" | typeof SCHEMA_INCOMPATIBLE;
25
41
 
@@ -41,7 +57,7 @@ export interface Refusal {
41
57
  */
42
58
  export function questionRefusal(titles: readonly string[]): Refusal | null {
43
59
  if (titles.length === 0) return null;
44
- return { ok: false, code: "pending_questions", message: `답을 기다리는 질문이 ${titles.length}개 있습니다. 확인함에서 먼저 답해 주세요. 답한 뒤 업데이트할 수 있습니다.`,
60
+ return { ok: false, code: "pending_questions", message: say(PENDING_QUESTIONS, { count: titles.length }),
45
61
  count: titles.length, titles };
46
62
  }
47
63
 
@@ -73,9 +89,9 @@ export function pendingMatches(pending: ConsentRecord | null, notice: Permission
73
89
  /** The lines to confirm before an engine change, or null when the shared store needs no warning. */
74
90
  export function schemaLines(target: TargetInfo, store: StoreReading, globalEngine: string | null): readonly string[] | null {
75
91
  const next = target.engine?.threadSchema ?? null;
76
- if (next === null) return [UNKNOWN_SCHEMA_LINE, ROLLBACK_SCHEMA_WARNING];
92
+ if (next === null) return [say(UNKNOWN_SCHEMA_LINE), say(ROLLBACK_SCHEMA_WARNING)];
77
93
  const notice = updateSchemaNotice({ store, next, other: globalEngine === null ? null : { version: globalEngine, supported: null } });
78
- return notice.confirm ? [...notice.lines, ROLLBACK_SCHEMA_WARNING] : null;
94
+ return notice.confirm ? [...notice.lines, say(ROLLBACK_SCHEMA_WARNING)] : null;
79
95
  }
80
96
 
81
97
  /**
@@ -85,27 +101,27 @@ export function schemaLines(target: TargetInfo, store: StoreReading, globalEngin
85
101
  export function schemaRefusal(target: TargetInfo, store: StoreReading): Refusal | null {
86
102
  const found = storeIncompatibility(store, target.engine?.threadSchema ?? null);
87
103
  if (found === null) return null;
88
- return refuse(SCHEMA_INCOMPATIBLE, `공유 폴더 스키마 ${found.store}를 ${target.version}의 엔진 ${target.engine?.omo ?? ""}(지원 ${found.supported})이 읽지 못해 업데이트하지 않았습니다. 스키마 ${found.store}를 지원하는 버전이 필요합니다`,
89
- { lines: [`저장소 스키마 ${found.store}`, `대상 엔진 지원 ${found.supported}`] });
104
+ return refuse(SCHEMA_INCOMPATIBLE, say(SCHEMA_UNREADABLE, { store: found.store, version: target.version, omo: target.engine?.omo ?? "", supported: found.supported }),
105
+ { lines: [say(STORE_SCHEMA_LINE, { store: found.store }), say(TARGET_SUPPORT_LINE, { supported: found.supported })] });
90
106
  }
91
107
 
92
108
  export function precheck(input: PrecheckInput): Precheck {
93
- if (input.kind !== "npm") return refuse("not_updatable", CHECKOUT_MESSAGE);
94
- if (input.running) return refuse("in_progress", "업데이트가 이미 진행 중입니다");
95
- if (input.target.version === input.current.version) return refuse("same_version", `이미 ${input.current.version}입니다`);
109
+ if (input.kind !== "npm") return refuse("not_updatable", say(CHECKOUT_MESSAGE));
110
+ if (input.running) return refuse("in_progress", say(IN_PROGRESS));
111
+ if (input.target.version === input.current.version) return refuse("same_version", say(SAME_VERSION, { version: input.current.version }));
96
112
  const incompatible = schemaRefusal(input.target, input.store);
97
113
  if (incompatible !== null) return incompatible;
98
- if (input.busy && !input.force && input.target.handoff !== true) return refuse("busy", BUSY_MESSAGE);
114
+ if (input.busy && !input.force && input.target.handoff !== true) return refuse("busy", say(BUSY_MESSAGE));
99
115
  const notice = input.target.notice;
100
116
  const noticeChanged = input.consent?.noticeVersion !== notice.version;
101
- if (noticeChanged && input.origin === "app") return refuse("consent_on_mac", CONSENT_ON_MAC_MESSAGE);
117
+ if (noticeChanged && input.origin === "app") return refuse("consent_on_mac", say(CONSENT_ON_MAC_MESSAGE));
102
118
  if (noticeChanged && !pendingMatches(input.pending, notice)) {
103
- return refuse("consent_required", `새 권한 안내(안내문 버전 ${notice.version})에 동의해야 업데이트합니다`, { notice });
119
+ return refuse("consent_required", say(CONSENT_REQUIRED, { version: notice.version }), { notice });
104
120
  }
105
121
  const engineChanged = input.target.engine !== null && (input.current.engineOmo === null ||
106
122
  engineBuildId(input.target.engine.omo, input.target.engine.revision) !== engineBuildId(input.current.engineOmo, input.current.engineRevision));
107
123
  const lines = engineChanged && !input.confirmSchema ? schemaLines(input.target, input.store, input.globalEngine) : null;
108
- if (lines !== null) return refuse("schema_confirm", ROLLBACK_SCHEMA_WARNING, { lines });
124
+ if (lines !== null) return refuse("schema_confirm", say(ROLLBACK_SCHEMA_WARNING), { lines });
109
125
  return { ok: true, noticeChanged, engineChanged };
110
126
  }
111
127
 
@@ -150,17 +166,17 @@ function stamp(now: Date): string {
150
166
 
151
167
  export async function requestUpdate(raw: unknown, origin: Origin, deps: UpdateApiDeps): Promise<ApiAnswer> {
152
168
  const body = parseUpdateBody(raw);
153
- if (body === null) return { status: 400, body: { ok: false, code: "bad_request", message: "본문은 { version?, force?, confirmSchema? } 입니다" } };
154
- if (deps.kind !== "npm") return { status: 409, body: refuse("not_updatable", CHECKOUT_MESSAGE) };
155
- if (updateRunning(readState(deps.home), deps.now().getTime())) return { status: 409, body: refuse("in_progress", "업데이트가 이미 진행 중입니다") };
156
- if (deps.draining?.() === true) return { status: 409, body: refuse("in_progress", DRAINING_MESSAGE) };
169
+ if (body === null) return { status: 400, body: { ok: false, code: "bad_request", message: say(BAD_UPDATE_BODY) } };
170
+ if (deps.kind !== "npm") return { status: 409, body: refuse("not_updatable", say(CHECKOUT_MESSAGE)) };
171
+ if (updateRunning(readState(deps.home), deps.now().getTime())) return { status: 409, body: refuse("in_progress", say(IN_PROGRESS)) };
172
+ if (deps.draining?.() === true) return { status: 409, body: refuse("in_progress", say(DRAINING_MESSAGE)) };
157
173
  const waiting = questionRefusal(deps.questions());
158
174
  if (waiting !== null) return { status: 409, body: waiting };
159
175
  let target: TargetInfo;
160
176
  try {
161
177
  target = await deps.resolve(body.version);
162
178
  } catch (error) {
163
- const message = error instanceof TargetError ? error.message : `대상 버전을 확인하지 못했습니다: ${error instanceof Error ? error.message : String(error)}`;
179
+ const message = error instanceof TargetError ? error.message : say(TARGET_CHECK_FAILED, { error: error instanceof Error ? error.message : String(error) });
164
180
  return { status: 502, body: { ok: false, code: "target_unavailable", message } };
165
181
  }
166
182
  const check = precheck({ kind: deps.kind, origin, current: deps.current, target, consent: deps.consent(), pending: deps.pending(), busy: deps.busy(),
@@ -173,7 +189,7 @@ export async function requestUpdate(raw: unknown, origin: Origin, deps: UpdateAp
173
189
  try {
174
190
  deps.snapshot(backup);
175
191
  } catch (error) {
176
- return { status: 500, body: { ok: false, code: "backup_failed", message: `DB 백업 실패: ${error instanceof Error ? error.message : String(error)}` } };
192
+ return { status: 500, body: { ok: false, code: "backup_failed", message: say(BACKUP_FAILED, { error: error instanceof Error ? error.message : String(error) }) } };
177
193
  }
178
194
  const request: UpdateRequest = { id, from: deps.current.version, to: target.version, source: target.source, noticeChanged: check.noticeChanged,
179
195
  engineChanged: check.engineChanged, backup, origin, requestedAt: now.toISOString() };
@@ -182,7 +198,7 @@ export async function requestUpdate(raw: unknown, origin: Origin, deps: UpdateAp
182
198
  const failed = await deps.launch();
183
199
  if (failed !== null) {
184
200
  writeState(deps.home, { id, from: request.from, to: request.to, phase: "done", at: now.toISOString(), result: { ok: false, from: request.from, to: request.to,
185
- rolledBack: false, logPath: "", message: `업데이트 도우미를 시작하지 못했습니다: ${failed}`, omo: null, finishedAt: now.toISOString() } });
201
+ rolledBack: false, logPath: "", message: say(HELPER_START_FAILED, { error: failed }), omo: null, finishedAt: now.toISOString() } });
186
202
  return { status: 500, body: { ok: false, code: "helper_failed", message: failed } };
187
203
  }
188
204
  return { status: 202, body: { ok: true, id, from: request.from, to: request.to } };
@@ -191,18 +207,18 @@ export async function requestUpdate(raw: unknown, origin: Origin, deps: UpdateAp
191
207
  /** The web window's (or the terminal's) `동의` for the target's new notice, kept as consent.pending.json until the helper promotes it. */
192
208
  export async function acceptUpdateConsent(raw: unknown, deps: Pick<UpdateApiDeps, "kind" | "home" | "resolve" | "now">): Promise<ApiAnswer> {
193
209
  const { version, noticeVersion, answer } = (raw ?? {}) as Record<string, unknown>;
194
- if (deps.kind !== "npm") return { status: 409, body: refuse("not_updatable", CHECKOUT_MESSAGE) };
210
+ if (deps.kind !== "npm") return { status: 409, body: refuse("not_updatable", say(CHECKOUT_MESSAGE)) };
195
211
  if (typeof version !== "string" || typeof noticeVersion !== "number" || typeof answer !== "string") {
196
- return { status: 400, body: { ok: false, code: "bad_request", message: "본문은 { version, noticeVersion, answer } 입니다" } };
212
+ return { status: 400, body: { ok: false, code: "bad_request", message: say(BAD_CONSENT_BODY) } };
197
213
  }
198
- if (answer.normalize("NFC") !== CONSENT_WORD) return { status: 400, body: { ok: false, code: "declined", message: "동의하지 않아 업데이트하지 않습니다" } };
214
+ if (answer.normalize("NFC") !== CONSENT_WORD) return { status: 400, body: { ok: false, code: "declined", message: say(DECLINED) } };
199
215
  let target: TargetInfo;
200
216
  try {
201
217
  target = await deps.resolve(version);
202
218
  } catch (error) {
203
219
  return { status: 502, body: { ok: false, code: "target_unavailable", message: error instanceof Error ? error.message : String(error) } };
204
220
  }
205
- if (target.notice.version !== noticeVersion) return { status: 409, body: { ok: false, code: "notice_changed", message: "권한 안내문이 그사이 바뀌었습니다. 다시 확인하세요" } };
221
+ if (target.notice.version !== noticeVersion) return { status: 409, body: { ok: false, code: "notice_changed", message: say(NOTICE_CHANGED) } };
206
222
  writePending(deps.home, { noticeVersion, noticeSha256: noticeSha256(target.notice.text), acceptedAt: deps.now().toISOString(), zigminiVersion: target.version });
207
223
  return { status: 200, body: { ok: true } };
208
224
  }