@flame0510/project-aether 1.5.1 → 1.5.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.
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Kept apart from lib/agent-recreate.ts so busy checks (lib/agent-busy.ts, the provider
5
5
  * sync) can ask which agents are being recreated without importing the recreate action.
6
+ * The machinery is lib/agent-job-state.ts; this file holds the table's types and names.
6
7
  */
7
- import { openDb } from '@/lib/db';
8
+ import { createJobState } from '@/lib/agent-job-state';
8
9
 
9
10
  export type RecreateStatus = 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
10
11
 
@@ -25,84 +26,42 @@ export interface AgentRecreateRow {
25
26
 
26
27
  const FINAL: readonly RecreateStatus[] = ['done', 'failed', 'interrupted'];
27
28
 
28
- export function insertRecreate(agentId: string, image: string): number {
29
- const db = openDb(false);
30
- try {
31
- const now = Date.now();
32
- const info = db.prepare(
33
- `INSERT INTO agent_recreates (agent_id, status, image, started_at, updated_at)
34
- VALUES (?, 'backing_up', ?, ?, ?)`,
35
- ).run(agentId, image, now, now);
36
- return Number(info.lastInsertRowid);
37
- } finally {
38
- db.close();
39
- }
40
- }
29
+ const state = createJobState({
30
+ table: 'agent_recreates',
31
+ initialStatus: 'backing_up',
32
+ activeStatuses: ACTIVE_RECREATE_STATUSES,
33
+ finalStatuses: FINAL,
34
+ insertColumns: ['image'],
35
+ writableColumns: ['backup_file'],
36
+ });
41
37
 
42
38
  type Writable = Partial<Pick<AgentRecreateRow, 'status' | 'backup_file' | 'error'>>;
43
39
 
40
+ export function insertRecreate(agentId: string, image: string): number {
41
+ return state.insert(agentId, image);
42
+ }
43
+
44
44
  export function updateRecreateRow(id: number, fields: Writable): void {
45
- const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
46
- const now = Date.now();
47
- const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
48
- const values: unknown[] = [...entries.map(([, v]) => v), now];
49
- if (fields.status && FINAL.includes(fields.status)) {
50
- sets.push('finished_at = ?');
51
- values.push(now);
52
- } else if (fields.status) {
53
- sets.push('finished_at = NULL');
54
- }
55
- const db = openDb(false);
56
- try {
57
- db.prepare(`UPDATE agent_recreates SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
58
- } finally {
59
- db.close();
60
- }
45
+ state.update(id, fields);
61
46
  }
62
47
 
63
48
  export function latestRecreate(agentId: string): AgentRecreateRow | null {
64
- const db = openDb(true);
65
- try {
66
- return (db.prepare('SELECT * FROM agent_recreates WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRecreateRow | undefined) ?? null;
67
- } finally {
68
- db.close();
69
- }
49
+ return state.latest<AgentRecreateRow>(agentId);
70
50
  }
71
51
 
72
52
  export function isRecreateActive(agentId: string): boolean {
73
- const row = latestRecreate(agentId);
74
- return !!row && ACTIVE_RECREATE_STATUSES.includes(row.status);
53
+ return state.isActive(agentId);
75
54
  }
76
55
 
77
- /** AGENT_IDs with a recreate in an active status. */
78
56
  export function activeRecreateAgentIds(): Set<string> {
79
- const db = openDb(true);
80
- try {
81
- const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
82
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_recreates WHERE status IN (${placeholders})`).all(...ACTIVE_RECREATE_STATUSES) as { agent_id: string }[];
83
- return new Set(rows.map((r) => r.agent_id));
84
- } finally {
85
- db.close();
86
- }
57
+ return state.activeAgentIds();
87
58
  }
88
59
 
89
- /**
90
- * At startup no recreate job can be running: any row still active was cut off by the
91
- * restart. It becomes `interrupted`, keeping the step it was in.
92
- */
93
60
  export function markInterruptedRecreates(): number {
94
- const db = openDb(false);
95
- try {
96
- const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
97
- const now = Date.now();
98
- const info = db.prepare(
99
- `UPDATE agent_recreates
100
- SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
101
- status = 'interrupted', updated_at = ?, finished_at = ?
102
- WHERE status IN (${placeholders})`,
103
- ).run(now, now, ...ACTIVE_RECREATE_STATUSES);
104
- return info.changes;
105
- } finally {
106
- db.close();
107
- }
61
+ return state.markInterrupted();
62
+ }
63
+
64
+ /** Keep the newest `keepPerAgent` recreates of each agent; the rest is history. */
65
+ export function pruneRecreates(keepPerAgent: number): number {
66
+ return state.prune(keepPerAgent);
108
67
  }
@@ -2,9 +2,10 @@
2
2
  * Persisted state of agent restores: the `agent_restores` table (lib/db-bootstrap.mjs).
3
3
  *
4
4
  * Kept apart from lib/agent-restore.ts so busy checks (lib/agent-busy.ts) can ask which
5
- * agents are being restored without importing the restore action.
5
+ * agents are being restored without importing the restore action. The machinery is
6
+ * lib/agent-job-state.ts; this file holds the table's types and names.
6
7
  */
7
- import { openDb } from '@/lib/db';
8
+ import { createJobState } from '@/lib/agent-job-state';
8
9
 
9
10
  export type RestoreStatus = 'restoring' | 'done' | 'failed' | 'interrupted';
10
11
 
@@ -24,84 +25,42 @@ export interface AgentRestoreRow {
24
25
 
25
26
  const FINAL: readonly RestoreStatus[] = ['done', 'failed', 'interrupted'];
26
27
 
27
- export function insertRestore(agentId: string, file: string): number {
28
- const db = openDb(false);
29
- try {
30
- const now = Date.now();
31
- const info = db.prepare(
32
- `INSERT INTO agent_restores (agent_id, status, file, started_at, updated_at)
33
- VALUES (?, 'restoring', ?, ?, ?)`,
34
- ).run(agentId, file, now, now);
35
- return Number(info.lastInsertRowid);
36
- } finally {
37
- db.close();
38
- }
39
- }
28
+ const state = createJobState({
29
+ table: 'agent_restores',
30
+ initialStatus: 'restoring',
31
+ activeStatuses: ACTIVE_RESTORE_STATUSES,
32
+ finalStatuses: FINAL,
33
+ insertColumns: ['file'],
34
+ writableColumns: [],
35
+ });
40
36
 
41
37
  type Writable = Partial<Pick<AgentRestoreRow, 'status' | 'error'>>;
42
38
 
39
+ export function insertRestore(agentId: string, file: string): number {
40
+ return state.insert(agentId, file);
41
+ }
42
+
43
43
  export function updateRestoreRow(id: number, fields: Writable): void {
44
- const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
45
- const now = Date.now();
46
- const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
47
- const values: unknown[] = [...entries.map(([, v]) => v), now];
48
- if (fields.status && FINAL.includes(fields.status)) {
49
- sets.push('finished_at = ?');
50
- values.push(now);
51
- } else if (fields.status) {
52
- sets.push('finished_at = NULL');
53
- }
54
- const db = openDb(false);
55
- try {
56
- db.prepare(`UPDATE agent_restores SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
57
- } finally {
58
- db.close();
59
- }
44
+ state.update(id, fields);
60
45
  }
61
46
 
62
47
  export function latestRestore(agentId: string): AgentRestoreRow | null {
63
- const db = openDb(true);
64
- try {
65
- return (db.prepare('SELECT * FROM agent_restores WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRestoreRow | undefined) ?? null;
66
- } finally {
67
- db.close();
68
- }
48
+ return state.latest<AgentRestoreRow>(agentId);
69
49
  }
70
50
 
71
51
  export function isRestoreActive(agentId: string): boolean {
72
- const row = latestRestore(agentId);
73
- return !!row && ACTIVE_RESTORE_STATUSES.includes(row.status);
52
+ return state.isActive(agentId);
74
53
  }
75
54
 
76
- /** AGENT_IDs with a restore in an active status. */
77
55
  export function activeRestoreAgentIds(): Set<string> {
78
- const db = openDb(true);
79
- try {
80
- const placeholders = ACTIVE_RESTORE_STATUSES.map(() => '?').join(', ');
81
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_restores WHERE status IN (${placeholders})`).all(...ACTIVE_RESTORE_STATUSES) as { agent_id: string }[];
82
- return new Set(rows.map((r) => r.agent_id));
83
- } finally {
84
- db.close();
85
- }
56
+ return state.activeAgentIds();
86
57
  }
87
58
 
88
- /**
89
- * At startup no restore job can be running: a row still active was cut off by the
90
- * restart. It becomes `interrupted`, keeping the archive it was restoring.
91
- */
92
59
  export function markInterruptedRestores(): number {
93
- const db = openDb(false);
94
- try {
95
- const placeholders = ACTIVE_RESTORE_STATUSES.map(() => '?').join(', ');
96
- const now = Date.now();
97
- const info = db.prepare(
98
- `UPDATE agent_restores
99
- SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
100
- status = 'interrupted', updated_at = ?, finished_at = ?
101
- WHERE status IN (${placeholders})`,
102
- ).run(now, now, ...ACTIVE_RESTORE_STATUSES);
103
- return info.changes;
104
- } finally {
105
- db.close();
106
- }
60
+ return state.markInterrupted();
61
+ }
62
+
63
+ /** Keep the newest `keepPerAgent` restores of each agent; the rest is history. */
64
+ export function pruneRestores(keepPerAgent: number): number {
65
+ return state.prune(keepPerAgent);
107
66
  }
@@ -123,19 +123,42 @@ export function agentRestoreView(agentId: string): AgentRestoreView | null {
123
123
 
124
124
  /**
125
125
  * At startup no restore job can be running: rows still active were cut off by the
126
- * restart. They become `interrupted`; a container left stopped is started again,
127
- * best-effort, so nothing stays down.
126
+ * restart. A restore interrupted **mid-extract** leaves a half-written volume, so
127
+ * starting the container would present a partial workspace as if it were the restore: the
128
+ * extract is run again from the same archive (it is the source of truth and the file was
129
+ * checked before the job started), in the background, and the row lands in its final
130
+ * status. When the archive is gone the container is started on whatever the volume holds,
131
+ * and the row says so.
128
132
  */
129
133
  export async function recoverInterruptedRestores(): Promise<number> {
130
134
  const ids = activeRestoreAgentIds();
131
135
  if (ids.size === 0) return 0;
132
136
  const interrupted = markInterruptedRestores();
133
137
  for (const agentId of ids) {
134
- const container = await inspectAgentContainer(agentId).catch(() => null);
135
- const name = container?.Name?.replace(/^\//, '');
136
- if (!name || container?.State?.Running) continue;
138
+ const row = latestRestore(agentId);
139
+ const name = (await inspectAgentContainer(agentId).catch(() => null))?.Name?.replace(/^\//, '');
140
+ if (row?.file && (await backupFileExists(agentId, row.file))) {
141
+ console.log(`[agent:restore] ${agentId}: re-running the interrupted restore from ${row.file}`);
142
+ void runRestore(row.id, agentId, row.file).finally(() => undefined);
143
+ continue;
144
+ }
145
+ if (!name) continue;
137
146
  const started = await startAndVerifyContainer(agentId, name);
138
147
  if (!started.ok) console.warn(`[agent:restore] ${agentId} did not come back after an interrupted restore: ${started.error}`);
139
148
  }
140
149
  return interrupted;
141
150
  }
151
+
152
+ /** Whether the archive still exists in the backup volume. */
153
+ async function backupFileExists(agentId: string, file: string): Promise<boolean> {
154
+ if (!isValidBackupFile(agentId, file)) return false;
155
+ try {
156
+ await runDocker(
157
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'test -f "/backup/$FILE"'],
158
+ { timeoutMs: 30_000, env: { FILE: file } },
159
+ );
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
@@ -2,9 +2,10 @@
2
2
  * Persisted state of agent updates: the `agent_upgrades` table (lib/db-bootstrap.mjs).
3
3
  *
4
4
  * Kept apart from lib/agent-update.ts so the provider sync can ask which agents are
5
- * being updated without importing the update action, which imports the sync.
5
+ * being updated without importing the update action, which imports the sync. The
6
+ * machinery is lib/agent-job-state.ts; this file holds the table's types and names.
6
7
  */
7
- import { openDb } from '@/lib/db';
8
+ import { createJobState } from '@/lib/agent-job-state';
8
9
 
9
10
  export type UpdateStatus =
10
11
  | 'pending'
@@ -38,65 +39,36 @@ export interface AgentUpdateRow {
38
39
 
39
40
  const FINAL: readonly UpdateStatus[] = ['done', 'failed', 'interrupted', 'rolled_back', 'rollback_failed'];
40
41
 
41
- export function insertUpdate(agentId: string, fromVersion: string | null, toVersion: string): number {
42
- const db = openDb(false);
43
- try {
44
- const now = Date.now();
45
- const info = db.prepare(
46
- `INSERT INTO agent_upgrades (agent_id, status, from_version, to_version, started_at, updated_at)
47
- VALUES (?, 'pending', ?, ?, ?, ?)`,
48
- ).run(agentId, fromVersion, toVersion, now, now);
49
- return Number(info.lastInsertRowid);
50
- } finally {
51
- db.close();
52
- }
53
- }
42
+ const state = createJobState({
43
+ table: 'agent_upgrades',
44
+ initialStatus: 'pending',
45
+ activeStatuses: ACTIVE_STATUSES,
46
+ finalStatuses: FINAL,
47
+ insertColumns: ['from_version', 'to_version'],
48
+ writableColumns: ['backup_file', 'baseline_json', 'verify_json'],
49
+ });
54
50
 
55
51
  type Writable = Partial<Pick<AgentUpdateRow, 'status' | 'backup_file' | 'baseline_json' | 'verify_json' | 'error'>>;
56
52
 
53
+ export function insertUpdate(agentId: string, fromVersion: string | null, toVersion: string): number {
54
+ return state.insert(agentId, fromVersion, toVersion);
55
+ }
56
+
57
57
  export function updateRow(id: number, fields: Writable): void {
58
- const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
59
- const now = Date.now();
60
- const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
61
- const values: unknown[] = [...entries.map(([, v]) => v), now];
62
- if (fields.status && FINAL.includes(fields.status)) {
63
- sets.push('finished_at = ?');
64
- values.push(now);
65
- } else if (fields.status) {
66
- sets.push('finished_at = NULL');
67
- }
68
- const db = openDb(false);
69
- try {
70
- db.prepare(`UPDATE agent_upgrades SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
71
- } finally {
72
- db.close();
73
- }
58
+ state.update(id, fields);
74
59
  }
75
60
 
76
61
  export function latestUpdate(agentId: string): AgentUpdateRow | null {
77
- const db = openDb(true);
78
- try {
79
- return (db.prepare('SELECT * FROM agent_upgrades WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentUpdateRow | undefined) ?? null;
80
- } finally {
81
- db.close();
82
- }
62
+ return state.latest<AgentUpdateRow>(agentId);
83
63
  }
84
64
 
85
65
  export function isUpdateActive(agentId: string): boolean {
86
- const row = latestUpdate(agentId);
87
- return !!row && ACTIVE_STATUSES.includes(row.status);
66
+ return state.isActive(agentId);
88
67
  }
89
68
 
90
69
  /** AGENT_IDs with an update in an active status. */
91
70
  export function activeUpdateAgentIds(): Set<string> {
92
- const db = openDb(true);
93
- try {
94
- const placeholders = ACTIVE_STATUSES.map(() => '?').join(', ');
95
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_upgrades WHERE status IN (${placeholders})`).all(...ACTIVE_STATUSES) as { agent_id: string }[];
96
- return new Set(rows.map((r) => r.agent_id));
97
- } finally {
98
- db.close();
99
- }
71
+ return state.activeAgentIds();
100
72
  }
101
73
 
102
74
  /**
@@ -105,18 +77,10 @@ export function activeUpdateAgentIds(): Set<string> {
105
77
  * between Rollback and a new Update.
106
78
  */
107
79
  export function markInterruptedUpdates(): number {
108
- const db = openDb(false);
109
- try {
110
- const placeholders = ACTIVE_STATUSES.map(() => '?').join(', ');
111
- const now = Date.now();
112
- const info = db.prepare(
113
- `UPDATE agent_upgrades
114
- SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
115
- status = 'interrupted', updated_at = ?, finished_at = ?
116
- WHERE status IN (${placeholders})`,
117
- ).run(now, now, ...ACTIVE_STATUSES);
118
- return info.changes;
119
- } finally {
120
- db.close();
121
- }
80
+ return state.markInterrupted();
81
+ }
82
+
83
+ /** Keep the newest `keepPerAgent` updates of each agent; the rest is history. */
84
+ export function pruneUpdates(keepPerAgent: number): number {
85
+ return state.prune(keepPerAgent);
122
86
  }
@@ -287,6 +287,12 @@ export interface ApprovedPairing {
287
287
  export interface PairingState {
288
288
  pending: PendingPairing[];
289
289
  approved: ApprovedPairing[];
290
+ /**
291
+ * Why the approved list is empty, when that is not simply "none": the pairing store
292
+ * could not be read (the writer is not in this OpenClaw build, or the read failed).
293
+ * Callers must show it instead of "No approved senders", which would be a guess.
294
+ */
295
+ error?: string | null;
290
296
  }
291
297
 
292
298
  /**
@@ -428,8 +434,13 @@ export async function getPairings(agentId: string, channel: string = 'telegram')
428
434
  if (store.status === 'ok') {
429
435
  for (const entry of store.entries ?? []) approved.push({ senderId: String(entry) });
430
436
  }
437
+ // 'absent' is a real answer (no store yet, so no approved senders); anything else means
438
+ // the read did not succeed and an empty list must not be presented as "none".
439
+ const error = store.status === 'ok' || store.status === 'absent'
440
+ ? null
441
+ : (store.error ?? 'The approved senders could not be read');
431
442
 
432
- return { pending, approved };
443
+ return { pending, approved, error };
433
444
  }
434
445
 
435
446
  /** Approve a pending pairing code */