talon-agent 5.2.2 → 5.4.0
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.md +10 -6
- package/package.json +1 -1
- package/src/app.ts +78 -0
- package/src/backend/agy/auth.ts +128 -0
- package/src/backend/agy/constants.ts +88 -0
- package/src/backend/agy/doctor.ts +161 -0
- package/src/backend/agy/effort.ts +76 -0
- package/src/backend/agy/events.ts +402 -0
- package/src/backend/agy/factory.ts +137 -0
- package/src/backend/agy/handler/index.ts +9 -0
- package/src/backend/agy/handler/message.ts +444 -0
- package/src/backend/agy/init.ts +64 -0
- package/src/backend/agy/mcp/config.ts +336 -0
- package/src/backend/agy/mcp/register.ts +134 -0
- package/src/backend/agy/models.ts +335 -0
- package/src/backend/agy/one-shot.ts +301 -0
- package/src/backend/agy/process/child.ts +378 -0
- package/src/backend/agy/process/orphans.ts +106 -0
- package/src/backend/agy/sessions.ts +90 -0
- package/src/backend/agy/state.ts +77 -0
- package/src/backend/builtins.ts +1 -0
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/runtime/index.ts +1 -1
- package/src/cli/commands/backup.ts +396 -0
- package/src/cli/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- package/src/core/backup/archive/digest.ts +77 -0
- package/src/core/backup/archive/tar.ts +567 -0
- package/src/core/backup/archive/zstd.ts +31 -0
- package/src/core/backup/index.ts +54 -0
- package/src/core/backup/plan.ts +273 -0
- package/src/core/backup/restore.ts +410 -0
- package/src/core/backup/scheduler.ts +357 -0
- package/src/core/backup/snapshot.ts +408 -0
- package/src/core/backup/status.ts +194 -0
- package/src/core/backup/store.ts +312 -0
- package/src/core/backup/targets.ts +281 -0
- package/src/core/backup/types.ts +96 -0
- package/src/core/backup/upload.ts +172 -0
- package/src/core/bus/events.ts +45 -1
- package/src/core/config/index.ts +53 -0
- package/src/core/engine/gateway-actions/backup/index.ts +129 -0
- package/src/core/engine/gateway-actions/index.ts +4 -0
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/plugin/actions.ts +34 -0
- package/src/core/plugin/index.ts +5 -1
- package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/backup.ts +67 -0
- package/src/core/tools/types.ts +2 -1
- package/src/core/update/self-update.ts +3 -0
- package/src/frontend/discord/callbacks/components/index.ts +3 -0
- package/src/frontend/discord/commands/backup.ts +203 -0
- package/src/frontend/discord/commands/definitions.ts +35 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/telegram/callbacks/backup.ts +55 -0
- package/src/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/commands/backup.ts +209 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +2 -0
- package/src/storage/backup/index.ts +82 -0
- package/src/storage/backup/repo.ts +164 -0
- package/src/storage/db.ts +20 -0
- package/src/storage/sql/backups.sql +46 -0
- package/src/storage/sql/db.sql +8 -0
- package/src/storage/sql/schema.sql +30 -0
- package/src/storage/sql/statements.generated.ts +60 -1
- package/src/util/log.ts +1 -0
- /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* model:nav:* (pager)
|
|
19
19
|
* model:backend-select (select menu)
|
|
20
20
|
* metrics:today | metrics:all
|
|
21
|
+
* backup:restore:<id> | backup:cancel (restore confirmation, admin only)
|
|
21
22
|
* ai:<id> (AI-generated buttons — forwarded to agent)
|
|
22
23
|
*/
|
|
23
24
|
|
|
@@ -36,6 +37,7 @@ import { handleMetricsComponent } from "./metrics.js";
|
|
|
36
37
|
import { handleModelComponent } from "./model.js";
|
|
37
38
|
import { handlePulseComponent } from "./pulse.js";
|
|
38
39
|
import { handleSettingsComponent } from "./settings.js";
|
|
40
|
+
import { handleBackupComponent } from "../../commands/backup.js";
|
|
39
41
|
import type { ComponentHandlers, ComponentInteraction } from "./types.js";
|
|
40
42
|
|
|
41
43
|
// Null-prototype so a custom id of "toString:" / "constructor:" / etc.
|
|
@@ -48,6 +50,7 @@ export const COMPONENT_HANDLERS: ComponentHandlers = Object.assign(
|
|
|
48
50
|
"effort:": handleEffortComponent,
|
|
49
51
|
"model:": handleModelComponent,
|
|
50
52
|
"metrics:": handleMetricsComponent,
|
|
53
|
+
"backup:": handleBackupComponent,
|
|
51
54
|
"ai:": forwardToAgent,
|
|
52
55
|
} satisfies ComponentHandlers,
|
|
53
56
|
);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /backup — snapshots and checkpoints (admin only), the Discord half.
|
|
3
|
+
*
|
|
4
|
+
* Same surface as Telegram's /backup: status, now, checkpoint, list,
|
|
5
|
+
* pin/unpin, restore. Restore is behind a button (`backup:restore:<id>`)
|
|
6
|
+
* because it replaces the database, memory and identity of a running
|
|
7
|
+
* agent — and even then it does not restore in place: the request is
|
|
8
|
+
* staged to ~/.talon/restore-pending.json and applied by the next boot,
|
|
9
|
+
* before anything opens the database.
|
|
10
|
+
*
|
|
11
|
+
* The button handler lives here rather than under callbacks/components/
|
|
12
|
+
* so the whole command — prompt, confirmation and staging — reads as one
|
|
13
|
+
* file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ActionRowBuilder,
|
|
18
|
+
ButtonBuilder,
|
|
19
|
+
ButtonStyle,
|
|
20
|
+
MessageFlags,
|
|
21
|
+
type ChatInputCommandInteraction,
|
|
22
|
+
} from "discord.js";
|
|
23
|
+
import {
|
|
24
|
+
collectBackupStatus,
|
|
25
|
+
formatBackupStatus,
|
|
26
|
+
formatSnapshotList,
|
|
27
|
+
isSnapshotId,
|
|
28
|
+
listSnapshots,
|
|
29
|
+
readManifest,
|
|
30
|
+
runBackup,
|
|
31
|
+
setSnapshotPinned,
|
|
32
|
+
writeRestorePending,
|
|
33
|
+
} from "../../../core/backup/index.js";
|
|
34
|
+
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
35
|
+
import { logError } from "../../../util/log.js";
|
|
36
|
+
import { escapeForCodeBlock } from "../formatting.js";
|
|
37
|
+
import { isAdmin } from "../handlers/index.js";
|
|
38
|
+
import type { ComponentInteraction } from "../callbacks/components/types.js";
|
|
39
|
+
import { reply } from "./interaction.js";
|
|
40
|
+
|
|
41
|
+
function block(text: string): string {
|
|
42
|
+
return `\`\`\`\n${escapeForCodeBlock(text)}\n\`\`\``;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function confirmRow(id: string): ActionRowBuilder<ButtonBuilder> {
|
|
46
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
47
|
+
new ButtonBuilder()
|
|
48
|
+
.setCustomId(`backup:restore:${id}`)
|
|
49
|
+
.setLabel("Restore and restart")
|
|
50
|
+
.setStyle(ButtonStyle.Danger),
|
|
51
|
+
new ButtonBuilder()
|
|
52
|
+
.setCustomId("backup:cancel")
|
|
53
|
+
.setLabel("Cancel")
|
|
54
|
+
.setStyle(ButtonStyle.Secondary),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function takeSnapshot(
|
|
59
|
+
i: ChatInputCommandInteraction,
|
|
60
|
+
label?: string,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
63
|
+
try {
|
|
64
|
+
const manifest = await runBackup({
|
|
65
|
+
kind: label ? "checkpoint" : "backup",
|
|
66
|
+
label,
|
|
67
|
+
pinned: Boolean(label),
|
|
68
|
+
trigger: "command",
|
|
69
|
+
});
|
|
70
|
+
await i.editReply(
|
|
71
|
+
`✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
|
|
72
|
+
`${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
|
|
73
|
+
(label ? " (pinned)" : ""),
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logError("backup", "/backup now failed", err);
|
|
77
|
+
await i.editReply(
|
|
78
|
+
`⚠️ Backup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function askToRestore(
|
|
84
|
+
i: ChatInputCommandInteraction,
|
|
85
|
+
id: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (!isSnapshotId(id)) {
|
|
88
|
+
await reply(i, "That is not a snapshot id.", true);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const manifest = await readManifest(id);
|
|
92
|
+
if (!manifest) {
|
|
93
|
+
await reply(i, `No snapshot \`${id}\` on this machine.`, true);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await i.reply({
|
|
97
|
+
content:
|
|
98
|
+
`♻️ **Restore \`${id}\`?**\n` +
|
|
99
|
+
(manifest.label ? `“${manifest.label}”\n` : "") +
|
|
100
|
+
`Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
|
|
101
|
+
"This replaces config, prompts, keys, sessions, the database and memory, " +
|
|
102
|
+
"then restarts. A pinned checkpoint of the current state is taken first.",
|
|
103
|
+
components: [confirmRow(id).toJSON()],
|
|
104
|
+
flags: MessageFlags.Ephemeral,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `/backup <subcommand> [arg]`. */
|
|
109
|
+
export async function handleBackup(
|
|
110
|
+
i: ChatInputCommandInteraction,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
if (!isAdmin(i.user.id)) {
|
|
113
|
+
await reply(i, "Not authorized.", true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const subcommand = i.options.getString("command") ?? "status";
|
|
117
|
+
const argument = (i.options.getString("arg") ?? "").trim();
|
|
118
|
+
switch (subcommand) {
|
|
119
|
+
case "now":
|
|
120
|
+
await takeSnapshot(i);
|
|
121
|
+
return;
|
|
122
|
+
case "checkpoint":
|
|
123
|
+
if (!argument) {
|
|
124
|
+
await reply(i, "Give the checkpoint a label.", true);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await takeSnapshot(i, argument);
|
|
128
|
+
return;
|
|
129
|
+
case "list":
|
|
130
|
+
await reply(i, block(formatSnapshotList(await listSnapshots())), true);
|
|
131
|
+
return;
|
|
132
|
+
case "pin":
|
|
133
|
+
case "unpin": {
|
|
134
|
+
if (!isSnapshotId(argument)) {
|
|
135
|
+
await reply(i, "That is not a snapshot id.", true);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const ok = await setSnapshotPinned(argument, subcommand === "pin");
|
|
139
|
+
await reply(
|
|
140
|
+
i,
|
|
141
|
+
ok
|
|
142
|
+
? `${subcommand === "pin" ? "📌 Pinned" : "Unpinned"} \`${argument}\``
|
|
143
|
+
: `No snapshot \`${argument}\``,
|
|
144
|
+
true,
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
case "restore":
|
|
149
|
+
await askToRestore(i, argument);
|
|
150
|
+
return;
|
|
151
|
+
default:
|
|
152
|
+
await reply(
|
|
153
|
+
i,
|
|
154
|
+
block(formatBackupStatus(await collectBackupStatus())),
|
|
155
|
+
true,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `backup:*` buttons. Returns false for ids this does not own, the way
|
|
162
|
+
* every component handler does, so a stale button is acked not answered.
|
|
163
|
+
*/
|
|
164
|
+
export async function handleBackupComponent(
|
|
165
|
+
interaction: ComponentInteraction,
|
|
166
|
+
): Promise<boolean> {
|
|
167
|
+
const [prefix, action, id] = interaction.customId.split(":");
|
|
168
|
+
if (prefix !== "backup") return false;
|
|
169
|
+
if (!isAdmin(interaction.user.id)) {
|
|
170
|
+
await interaction.update({ content: "Not authorized.", components: [] });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (action === "cancel") {
|
|
174
|
+
await interaction.update({ content: "Restore cancelled.", components: [] });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (action !== "restore" || !id || !isSnapshotId(id)) return false;
|
|
178
|
+
await interaction.update({
|
|
179
|
+
content:
|
|
180
|
+
`♻️ Restoring \`${id}\` — restarting now. The restore is applied during ` +
|
|
181
|
+
"boot; I will report back when I am up.",
|
|
182
|
+
components: [],
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
const chatId = interaction.guildId
|
|
186
|
+
? `discord_guild_${interaction.guildId}_${interaction.channelId}`
|
|
187
|
+
: `discord_dm_${interaction.user.id}`;
|
|
188
|
+
await writeRestorePending({
|
|
189
|
+
id,
|
|
190
|
+
requestedAt: Date.now(),
|
|
191
|
+
requestedBy: chatId,
|
|
192
|
+
});
|
|
193
|
+
respawnSelf(`discord /backup restore ${id}`);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logError("backup", "Staging the restore failed", err);
|
|
196
|
+
await interaction
|
|
197
|
+
.editReply({
|
|
198
|
+
content: `⚠️ Could not stage the restore: ${err instanceof Error ? err.message : String(err)}`,
|
|
199
|
+
})
|
|
200
|
+
.catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
@@ -17,6 +17,40 @@ import type { TalonConfig } from "../../../core/config/index.js";
|
|
|
17
17
|
import { log, logError, logWarn } from "../../../util/log.js";
|
|
18
18
|
import { getRepoRoot } from "../../../core/update/self-update.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* /backup — its own builder: the definitions list is one long literal and
|
|
22
|
+
* this is the only entry with two options and seven choices.
|
|
23
|
+
*/
|
|
24
|
+
function backupCommand(): unknown {
|
|
25
|
+
return new SlashCommandBuilder()
|
|
26
|
+
.setName("backup")
|
|
27
|
+
.setDescription("Snapshots and checkpoints (admin only)")
|
|
28
|
+
.addStringOption((o) =>
|
|
29
|
+
o
|
|
30
|
+
.setName("command")
|
|
31
|
+
.setDescription("What to do (default: status)")
|
|
32
|
+
.setRequired(false)
|
|
33
|
+
.addChoices(
|
|
34
|
+
{ name: "status", value: "status" },
|
|
35
|
+
{ name: "now", value: "now" },
|
|
36
|
+
{ name: "checkpoint", value: "checkpoint" },
|
|
37
|
+
{ name: "list", value: "list" },
|
|
38
|
+
{ name: "pin", value: "pin" },
|
|
39
|
+
{ name: "unpin", value: "unpin" },
|
|
40
|
+
{ name: "restore", value: "restore" },
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
.addStringOption((o) =>
|
|
44
|
+
o
|
|
45
|
+
.setName("arg")
|
|
46
|
+
.setDescription(
|
|
47
|
+
"Checkpoint label, or snapshot id for pin/unpin/restore",
|
|
48
|
+
)
|
|
49
|
+
.setRequired(false),
|
|
50
|
+
)
|
|
51
|
+
.toJSON();
|
|
52
|
+
}
|
|
53
|
+
|
|
20
54
|
function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
21
55
|
return [
|
|
22
56
|
new SlashCommandBuilder()
|
|
@@ -125,6 +159,7 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
|
125
159
|
.toJSON(),
|
|
126
160
|
]
|
|
127
161
|
: []),
|
|
162
|
+
backupCommand(),
|
|
128
163
|
new SlashCommandBuilder()
|
|
129
164
|
.setName("admin")
|
|
130
165
|
.setDescription("Admin operations (admin only)")
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
handlePulse,
|
|
38
38
|
handleSettings,
|
|
39
39
|
} from "./settings.js";
|
|
40
|
+
import { handleBackup } from "./backup.js";
|
|
40
41
|
import {
|
|
41
42
|
handleRestart,
|
|
42
43
|
handleMetrics,
|
|
@@ -160,6 +161,8 @@ async function routeSlashCommand(
|
|
|
160
161
|
return handleMesh(interaction);
|
|
161
162
|
case "update":
|
|
162
163
|
return handleUpdate(interaction, config);
|
|
164
|
+
case "backup":
|
|
165
|
+
return handleBackup(interaction);
|
|
163
166
|
case "admin":
|
|
164
167
|
return handleAdmin(interaction, config, gateway);
|
|
165
168
|
default:
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `backup:*` callbacks — the /backup restore confirmation.
|
|
3
|
+
*
|
|
4
|
+
* backup:restore:<id> stage the restore and restart
|
|
5
|
+
* backup:cancel do nothing, say so
|
|
6
|
+
*
|
|
7
|
+
* The button is the whole safety mechanism: a restore replaces the
|
|
8
|
+
* database, the memory and the identity of a running agent, so nothing
|
|
9
|
+
* here happens without a second, explicit admin tap.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Context } from "grammy";
|
|
13
|
+
import { isSnapshotId } from "../../../core/backup/index.js";
|
|
14
|
+
import { stageRestore } from "../commands/backup.js";
|
|
15
|
+
import { isAuthorizedAdmin } from "../commands/state.js";
|
|
16
|
+
import { logError } from "../../../util/log.js";
|
|
17
|
+
import { answerCallbackQuerySafe } from "./query.js";
|
|
18
|
+
|
|
19
|
+
export async function handleBackupCallback(
|
|
20
|
+
ctx: Context,
|
|
21
|
+
data: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
24
|
+
await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const [, action, id] = data.split(":");
|
|
28
|
+
if (action === "cancel") {
|
|
29
|
+
await answerCallbackQuerySafe(ctx, { text: "Cancelled." });
|
|
30
|
+
await ctx.editMessageText("Restore cancelled.").catch(() => {});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (action !== "restore" || !id || !isSnapshotId(id)) {
|
|
34
|
+
await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await answerCallbackQuerySafe(ctx, { text: "Restoring…" });
|
|
38
|
+
await ctx
|
|
39
|
+
.editMessageText(
|
|
40
|
+
`♻️ Restoring <code>${id}</code> — restarting now. ` +
|
|
41
|
+
"The restore is applied during boot; I will report back when I am up.",
|
|
42
|
+
{ parse_mode: "HTML" },
|
|
43
|
+
)
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
try {
|
|
46
|
+
await stageRestore(String(ctx.chat?.id ?? ctx.from?.id ?? ""), id);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logError("backup", "Staging the restore failed", err);
|
|
49
|
+
await ctx
|
|
50
|
+
.editMessageText(
|
|
51
|
+
`⚠️ Could not stage the restore: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
)
|
|
53
|
+
.catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - `model` — `model:*` (menu / backend / browse controller)
|
|
12
12
|
* - `auth` — `auth:*` (backend sign-in panel, admin only)
|
|
13
13
|
* - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
|
|
14
|
+
* - `backup` — `backup:*` (restore confirmation, admin only)
|
|
14
15
|
*
|
|
15
16
|
* `registerCallbacks` installs one `callback_query:data` listener that
|
|
16
17
|
* dispatches on the data prefix, preserving the original order and the
|
|
@@ -29,6 +30,7 @@ import { handleMetricsCallback } from "./metrics.js";
|
|
|
29
30
|
import { handleModelCallback } from "./model.js";
|
|
30
31
|
import { handleAuthCallback } from "./auth.js";
|
|
31
32
|
import { handleWhatsAppCallback } from "./whatsapp.js";
|
|
33
|
+
import { handleBackupCallback } from "./backup.js";
|
|
32
34
|
|
|
33
35
|
export { answerCallbackQuerySafe } from "./query.js";
|
|
34
36
|
|
|
@@ -50,6 +52,12 @@ export function registerCallbacks(
|
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// Restore confirmation — destructive, so it is behind its own tap.
|
|
56
|
+
if (data.startsWith("backup:")) {
|
|
57
|
+
await handleBackupCallback(ctx, data);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
// Handle pulse callbacks
|
|
54
62
|
if (data.startsWith("pulse:")) {
|
|
55
63
|
await handlePulseCallback(ctx, data, cid);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /backup — the snapshot panel (admin only).
|
|
3
|
+
*
|
|
4
|
+
* /backup status: schedule, sizes, targets
|
|
5
|
+
* /backup now take a snapshot now
|
|
6
|
+
* /backup checkpoint <label> take a labelled, pinned-on-request snapshot
|
|
7
|
+
* /backup list recent snapshots
|
|
8
|
+
* /backup pin|unpin <id> keep past retention, or release
|
|
9
|
+
* /backup restore <id> confirm button → staged restore + restart
|
|
10
|
+
*
|
|
11
|
+
* Restore is the only destructive one, so it is the only one behind a
|
|
12
|
+
* button. It does not restore in place: a running daemon holds the
|
|
13
|
+
* database it would replace, so the request is staged to
|
|
14
|
+
* ~/.talon/restore-pending.json and applied by the next boot before
|
|
15
|
+
* anything opens the database (see core/backup/restore.ts).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Bot, Context } from "grammy";
|
|
19
|
+
import {
|
|
20
|
+
collectBackupStatus,
|
|
21
|
+
formatBackupStatus,
|
|
22
|
+
formatSnapshotList,
|
|
23
|
+
isSnapshotId,
|
|
24
|
+
listSnapshots,
|
|
25
|
+
readManifest,
|
|
26
|
+
runBackup,
|
|
27
|
+
setSnapshotPinned,
|
|
28
|
+
writeRestorePending,
|
|
29
|
+
} from "../../../core/backup/index.js";
|
|
30
|
+
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
31
|
+
import { escapeHtml } from "../formatting.js";
|
|
32
|
+
import { logError } from "../../../util/log.js";
|
|
33
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
34
|
+
|
|
35
|
+
const HELP = [
|
|
36
|
+
"<b>/backup</b> — snapshots and checkpoints",
|
|
37
|
+
"",
|
|
38
|
+
"<code>/backup</code> — status",
|
|
39
|
+
"<code>/backup now</code> — take a snapshot",
|
|
40
|
+
"<code>/backup checkpoint <label></code> — labelled checkpoint",
|
|
41
|
+
"<code>/backup list</code> — recent snapshots",
|
|
42
|
+
"<code>/backup pin <id></code> · <code>/backup unpin <id></code>",
|
|
43
|
+
"<code>/backup restore <id></code> — restore (asks to confirm, then restarts)",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
function pre(text: string): string {
|
|
47
|
+
return `<pre>${escapeHtml(text)}</pre>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function sendStatus(ctx: Context): Promise<void> {
|
|
51
|
+
const status = await collectBackupStatus();
|
|
52
|
+
await ctx.reply(pre(formatBackupStatus(status)), { parse_mode: "HTML" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sendList(ctx: Context): Promise<void> {
|
|
56
|
+
const snapshots = await listSnapshots();
|
|
57
|
+
await ctx.reply(pre(formatSnapshotList(snapshots)), { parse_mode: "HTML" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function takeSnapshot(ctx: Context, label?: string): Promise<void> {
|
|
61
|
+
const sent = await ctx.reply(
|
|
62
|
+
label
|
|
63
|
+
? `📸 Taking checkpoint “${escapeHtml(label)}”…`
|
|
64
|
+
: "📸 Taking a snapshot…",
|
|
65
|
+
{ parse_mode: "HTML" },
|
|
66
|
+
);
|
|
67
|
+
try {
|
|
68
|
+
const manifest = await runBackup({
|
|
69
|
+
kind: label ? "checkpoint" : "backup",
|
|
70
|
+
label,
|
|
71
|
+
pinned: Boolean(label),
|
|
72
|
+
trigger: "command",
|
|
73
|
+
});
|
|
74
|
+
await ctx.api.editMessageText(
|
|
75
|
+
ctx.chat!.id,
|
|
76
|
+
sent.message_id,
|
|
77
|
+
`✅ <code>${escapeHtml(manifest.id)}</code> — ${manifest.parts.length} part(s), ` +
|
|
78
|
+
`${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
|
|
79
|
+
(label ? " (pinned)" : ""),
|
|
80
|
+
{ parse_mode: "HTML" },
|
|
81
|
+
);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logError("backup", "/backup now failed", err);
|
|
84
|
+
await ctx.api.editMessageText(
|
|
85
|
+
ctx.chat!.id,
|
|
86
|
+
sent.message_id,
|
|
87
|
+
`⚠️ Backup failed: ${escapeHtml(err instanceof Error ? err.message : String(err))}`,
|
|
88
|
+
{ parse_mode: "HTML" },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function setPinned(
|
|
94
|
+
ctx: Context,
|
|
95
|
+
id: string,
|
|
96
|
+
pinned: boolean,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (!isSnapshotId(id)) {
|
|
99
|
+
await ctx.reply("That is not a snapshot id.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const ok = await setSnapshotPinned(id, pinned);
|
|
103
|
+
await ctx.reply(
|
|
104
|
+
ok
|
|
105
|
+
? `${pinned ? "📌 Pinned" : "Unpinned"} <code>${escapeHtml(id)}</code>`
|
|
106
|
+
: `No snapshot <code>${escapeHtml(id)}</code>`,
|
|
107
|
+
{ parse_mode: "HTML" },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The confirmation prompt. The button carries the id; nothing happens yet. */
|
|
112
|
+
async function askToRestore(ctx: Context, id: string): Promise<void> {
|
|
113
|
+
if (!isSnapshotId(id)) {
|
|
114
|
+
await ctx.reply("That is not a snapshot id.");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const manifest = await readManifest(id);
|
|
118
|
+
if (!manifest) {
|
|
119
|
+
await ctx.reply(
|
|
120
|
+
`No snapshot <code>${escapeHtml(id)}</code> on this machine.`,
|
|
121
|
+
{
|
|
122
|
+
parse_mode: "HTML",
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await ctx.reply(
|
|
128
|
+
`♻️ <b>Restore <code>${escapeHtml(id)}</code>?</b>\n` +
|
|
129
|
+
(manifest.label ? `“${escapeHtml(manifest.label)}”\n` : "") +
|
|
130
|
+
`Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
|
|
131
|
+
"This replaces config, prompts, keys, sessions, the database and memory, " +
|
|
132
|
+
"then restarts. A pinned checkpoint of the current state is taken first.",
|
|
133
|
+
{
|
|
134
|
+
parse_mode: "HTML",
|
|
135
|
+
reply_markup: {
|
|
136
|
+
inline_keyboard: [
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
text: "♻️ Restore and restart",
|
|
140
|
+
callback_data: `backup:restore:${id}`,
|
|
141
|
+
},
|
|
142
|
+
{ text: "Cancel", callback_data: "backup:cancel" },
|
|
143
|
+
],
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stage the restore and hand off to the successor. Called by the
|
|
152
|
+
* confirmation button — see callbacks/backup.ts.
|
|
153
|
+
*/
|
|
154
|
+
export async function stageRestore(chatId: string, id: string): Promise<void> {
|
|
155
|
+
await writeRestorePending({
|
|
156
|
+
id,
|
|
157
|
+
requestedAt: Date.now(),
|
|
158
|
+
requestedBy: chatId,
|
|
159
|
+
});
|
|
160
|
+
respawnSelf(`telegram /backup restore ${id}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function registerBackupCommand(bot: Bot): void {
|
|
164
|
+
bot.command("backup", async (ctx) => {
|
|
165
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
166
|
+
await ctx.reply("Not authorized.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const [subcommand, ...rest] = (ctx.match ?? "")
|
|
170
|
+
.trim()
|
|
171
|
+
.split(/\s+/)
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
switch (subcommand) {
|
|
174
|
+
case undefined:
|
|
175
|
+
case "status":
|
|
176
|
+
await sendStatus(ctx);
|
|
177
|
+
return;
|
|
178
|
+
case "now":
|
|
179
|
+
await takeSnapshot(ctx);
|
|
180
|
+
return;
|
|
181
|
+
case "checkpoint": {
|
|
182
|
+
const label = rest.join(" ").trim();
|
|
183
|
+
if (!label) {
|
|
184
|
+
await ctx.reply(
|
|
185
|
+
"Give the checkpoint a label: <code>/backup checkpoint before the rewrite</code>",
|
|
186
|
+
{
|
|
187
|
+
parse_mode: "HTML",
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await takeSnapshot(ctx, label);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case "list":
|
|
196
|
+
await sendList(ctx);
|
|
197
|
+
return;
|
|
198
|
+
case "pin":
|
|
199
|
+
case "unpin":
|
|
200
|
+
await setPinned(ctx, rest[0] ?? "", subcommand === "pin");
|
|
201
|
+
return;
|
|
202
|
+
case "restore":
|
|
203
|
+
await askToRestore(ctx, rest[0] ?? "");
|
|
204
|
+
return;
|
|
205
|
+
default:
|
|
206
|
+
await ctx.reply(HELP, { parse_mode: "HTML" });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -48,6 +48,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
48
48
|
command: "memory",
|
|
49
49
|
description: "What Talon remembers — list, search, why <id>",
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
command: "backup",
|
|
53
|
+
description: "Snapshots and checkpoints; /backup restore <id> (admin)",
|
|
54
|
+
},
|
|
51
55
|
{ command: "plugins", description: "List loaded plugins" },
|
|
52
56
|
{ command: "help", description: "All commands and features" },
|
|
53
57
|
];
|
|
@@ -26,6 +26,7 @@ import { registerSettingsCommands } from "./settings.js";
|
|
|
26
26
|
import { registerAdminCommands } from "./admin.js";
|
|
27
27
|
import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
|
|
28
28
|
import { registerAuthCommand } from "./auth.js";
|
|
29
|
+
import { registerBackupCommand } from "./backup.js";
|
|
29
30
|
|
|
30
31
|
export { telegramCommandMenu } from "./definitions.js";
|
|
31
32
|
export { setAdminUserId } from "./state.js";
|
|
@@ -42,6 +43,7 @@ export function registerCommands(
|
|
|
42
43
|
registerSettingsCommands(bot, deps);
|
|
43
44
|
registerWhatsAppPairingCommand(bot);
|
|
44
45
|
registerAuthCommand(bot);
|
|
46
|
+
registerBackupCommand(bot);
|
|
45
47
|
// admin LAST: it owns the unknown-command catch-all, which must only
|
|
46
48
|
// be reached after every real command has had its chance to match.
|
|
47
49
|
// Registering anything after it makes that command look unknown
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot index store — the listing/status view over the backups on
|
|
3
|
+
* disk and on remote targets.
|
|
4
|
+
*
|
|
5
|
+
* Zero SQL by contract (see repo.ts). The domain rule this layer adds is
|
|
6
|
+
* atomicity: a snapshot and its per-target rows move together, so a
|
|
7
|
+
* pruned snapshot never leaves orphan remote rows behind to be counted
|
|
8
|
+
* by the next retention pass.
|
|
9
|
+
*
|
|
10
|
+
* It lives in its own directory rather than as `storage/backups.ts`
|
|
11
|
+
* because the flat `storage/` surface is at its documented ceiling
|
|
12
|
+
* (docs/structure.md, tree baseline) — the store and its repository are
|
|
13
|
+
* one concern with one entry, which is what the contract asks for.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { inTransaction } from "../db.js";
|
|
17
|
+
import * as repo from "./repo.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Re-exported so the backup subsystem can ask SQLite for a consistent
|
|
21
|
+
* copy of itself without importing the connection module: the database
|
|
22
|
+
* handle stays inside storage/ (`db-handle-stays-in-storage`), and a
|
|
23
|
+
* snapshot of the database is a storage concern with a storage API.
|
|
24
|
+
*/
|
|
25
|
+
export { snapshotDatabase } from "../db.js";
|
|
26
|
+
|
|
27
|
+
export type { BackupRecord, BackupRemoteRecord } from "./repo.js";
|
|
28
|
+
import type { BackupRecord, BackupRemoteRecord } from "./repo.js";
|
|
29
|
+
|
|
30
|
+
/** Insert or replace one snapshot row. */
|
|
31
|
+
export function recordBackup(record: BackupRecord): void {
|
|
32
|
+
repo.upsert(record);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getBackup(id: string): BackupRecord | undefined {
|
|
36
|
+
return repo.get(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every indexed snapshot, newest first. */
|
|
40
|
+
export function listBackups(): BackupRecord[] {
|
|
41
|
+
return repo.all();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ids only — for reconciling the index against the directories on disk. */
|
|
45
|
+
export function backupIds(): Set<string> {
|
|
46
|
+
return new Set(repo.ids());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns false when no such snapshot is indexed. */
|
|
50
|
+
export function pinBackup(id: string, pinned: boolean): boolean {
|
|
51
|
+
return repo.setPinned(id, pinned);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replace the stored manifest (after an upload, a pin, a remote prune). */
|
|
55
|
+
export function updateBackupManifest(
|
|
56
|
+
id: string,
|
|
57
|
+
manifestJson: string,
|
|
58
|
+
pinned: boolean,
|
|
59
|
+
sizeBytes: number,
|
|
60
|
+
): void {
|
|
61
|
+
repo.setManifest(id, manifestJson, pinned, sizeBytes);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop a snapshot and every remote row that referenced it. */
|
|
65
|
+
export function deleteBackup(id: string): void {
|
|
66
|
+
inTransaction(() => {
|
|
67
|
+
repo.removeRemotes(id);
|
|
68
|
+
repo.remove(id);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recordBackupRemote(record: BackupRemoteRecord): void {
|
|
73
|
+
repo.upsertRemote(record);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listBackupRemotes(backupId?: string): BackupRemoteRecord[] {
|
|
77
|
+
return backupId === undefined ? repo.remotesAll() : repo.remotesFor(backupId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function deleteBackupRemote(backupId: string, targetId: string): void {
|
|
81
|
+
repo.removeRemote(backupId, targetId);
|
|
82
|
+
}
|