zigmini 0.9.0-beta.3 → 1.0.0-beta.2
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 +36 -4
- package/README.md +36 -4
- package/assets/agent/gateway-rules.md +29 -19
- 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 +60 -11
- package/docs/manual.md +61 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +97 -13
- package/src/approvals.ts +6 -1
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +38 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/rules.ts +70 -0
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +172 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- 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,
|
|
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
|
|
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 :
|
|
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() ||
|
|
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() ||
|
|
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 ?
|
|
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: "" };
|
package/src/update/handoff.ts
CHANGED
|
@@ -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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
?
|
|
179
|
-
:
|
|
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
|
}
|
package/src/update/helper.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
241
|
+
await attempt(STEP_RESTORE_CONSENT, () => restorePrev(deps.home));
|
|
206
242
|
const probe = await deps.probe();
|
|
207
|
-
const message = [
|
|
208
|
-
problems.length === 0 ?
|
|
209
|
-
:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
237
|
-
await attempt(
|
|
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(
|
|
243
|
-
await attempt(
|
|
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(
|
|
288
|
+
if (!back) problems.push(say(OLD_WEB_DOWN, { from: request.from }));
|
|
253
289
|
const omo = probe?.omo ?? null;
|
|
254
|
-
const message = [
|
|
255
|
-
problems.length === 0 ?
|
|
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
|
}
|
package/src/update/request.ts
CHANGED
|
@@ -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:
|
|
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,
|
|
89
|
-
{ lines: [
|
|
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",
|
|
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",
|
|
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:
|
|
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 :
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|