@flame0510/project-aether 1.5.0 → 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.
- package/app/agents/ChannelManager.tsx +17 -5
- package/app/agents/PageClient.tsx +13 -3
- package/app/api/agents/download-image/route.ts +8 -0
- package/app/api/update-check/route.ts +26 -15
- package/app/components/VersionBanner.tsx +77 -12
- package/bin/rev4a.js +6 -3
- package/daemon.js +3 -3
- package/docs/ARCHITECTURE.md +25 -3
- package/docs/FRONTEND-ARCHITECTURE.md +3 -2
- package/docs/REV4A.md +17 -0
- package/docs/dev/API-REFERENCE.md +21 -4
- package/docs/rag/REV4A-OVERVIEW.md +7 -1
- package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
- package/instrumentation.ts +11 -0
- package/lib/agent-edit-state.ts +25 -66
- package/lib/agent-job-state.ts +145 -0
- package/lib/agent-jobs-maintenance.ts +34 -0
- package/lib/agent-recreate-state.ts +24 -65
- package/lib/agent-restore-state.ts +25 -66
- package/lib/agent-restore.ts +28 -5
- package/lib/agent-update-state.ts +25 -61
- package/lib/channelManager.ts +12 -1
- package/model-pricing.json +258 -22
- package/models.config.json +424 -6
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +5 -5
package/lib/agent-edit-state.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Persisted state of agent edits: the `agent_edits` table (lib/db-bootstrap.mjs).
|
|
3
3
|
*
|
|
4
4
|
* Kept apart from lib/agent-edit.ts so busy checks (lib/agent-busy.ts) can ask which
|
|
5
|
-
* agents are being edited without importing the edit action.
|
|
5
|
+
* agents are being edited without importing the edit action. The machinery is
|
|
6
|
+
* lib/agent-job-state.ts; this file holds the table's types and names.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
+
import { createJobState } from '@/lib/agent-job-state';
|
|
8
9
|
|
|
9
10
|
export type EditStatus = 'rebuilding' | 'done' | 'failed' | 'interrupted';
|
|
10
11
|
|
|
@@ -25,84 +26,42 @@ export interface AgentEditRow {
|
|
|
25
26
|
|
|
26
27
|
const FINAL: readonly EditStatus[] = ['done', 'failed', 'interrupted'];
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return Number(info.lastInsertRowid);
|
|
37
|
-
} finally {
|
|
38
|
-
db.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
29
|
+
const state = createJobState({
|
|
30
|
+
table: 'agent_edits',
|
|
31
|
+
initialStatus: 'rebuilding',
|
|
32
|
+
activeStatuses: ACTIVE_EDIT_STATUSES,
|
|
33
|
+
finalStatuses: FINAL,
|
|
34
|
+
insertColumns: ['display_name', 'port_range'],
|
|
35
|
+
writableColumns: [],
|
|
36
|
+
});
|
|
41
37
|
|
|
42
38
|
type Writable = Partial<Pick<AgentEditRow, 'status' | 'error'>>;
|
|
43
39
|
|
|
40
|
+
export function insertEdit(agentId: string, displayName: string | null, portRange: string | null): number {
|
|
41
|
+
return state.insert(agentId, displayName, portRange);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
export function updateEditRow(id: number, fields: Writable): void {
|
|
45
|
-
|
|
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_edits 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 latestEdit(agentId: string): AgentEditRow | null {
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
return (db.prepare('SELECT * FROM agent_edits WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentEditRow | undefined) ?? null;
|
|
67
|
-
} finally {
|
|
68
|
-
db.close();
|
|
69
|
-
}
|
|
49
|
+
return state.latest<AgentEditRow>(agentId);
|
|
70
50
|
}
|
|
71
51
|
|
|
72
52
|
export function isEditActive(agentId: string): boolean {
|
|
73
|
-
|
|
74
|
-
return !!row && ACTIVE_EDIT_STATUSES.includes(row.status);
|
|
53
|
+
return state.isActive(agentId);
|
|
75
54
|
}
|
|
76
55
|
|
|
77
|
-
/** AGENT_IDs with an edit in an active status. */
|
|
78
56
|
export function activeEditAgentIds(): Set<string> {
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const placeholders = ACTIVE_EDIT_STATUSES.map(() => '?').join(', ');
|
|
82
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_edits WHERE status IN (${placeholders})`).all(...ACTIVE_EDIT_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 edit job can be running: a row still active was cut off by the restart.
|
|
91
|
-
* It becomes `interrupted`, keeping the name and port it was applying.
|
|
92
|
-
*/
|
|
93
60
|
export function markInterruptedEdits(): number {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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_EDIT_STATUSES);
|
|
104
|
-
return info.changes;
|
|
105
|
-
} finally {
|
|
106
|
-
db.close();
|
|
107
|
-
}
|
|
61
|
+
return state.markInterrupted();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keep the newest `keepPerAgent` edits of each agent; the rest is history. */
|
|
65
|
+
export function pruneEdits(keepPerAgent: number): number {
|
|
66
|
+
return state.prune(keepPerAgent);
|
|
108
67
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four lifecycle job tables — `agent_upgrades`, `agent_recreates`, `agent_restores`,
|
|
3
|
+
* `agent_edits` — are the same machine: an insert that starts a row in one status, an
|
|
4
|
+
* update that stamps `updated_at` and `finished_at`, a latest lookup, an "is something
|
|
5
|
+
* running" check, an at-startup sweep that turns interrupted rows into `interrupted`, and
|
|
6
|
+
* a retention prune. They used to be four copies of that code, which drifted; this factory
|
|
7
|
+
* is the single implementation, and each table keeps its own types and function names.
|
|
8
|
+
*
|
|
9
|
+
* The table and column names come from this file's callers, never from a request, and
|
|
10
|
+
* `update` only writes the columns its spec allows: nothing user-supplied can reach the
|
|
11
|
+
* generated SQL.
|
|
12
|
+
*/
|
|
13
|
+
import { openDb } from '@/lib/db';
|
|
14
|
+
|
|
15
|
+
export interface JobStateSpec {
|
|
16
|
+
/** Table name (created by lib/db-bootstrap.mjs). */
|
|
17
|
+
table: string;
|
|
18
|
+
/** The status an inserted row starts in. */
|
|
19
|
+
initialStatus: string;
|
|
20
|
+
/** Statuses during which the agent must not be touched by anything else. */
|
|
21
|
+
activeStatuses: readonly string[];
|
|
22
|
+
/** Statuses that end a job: `finished_at` is stamped when one is set. */
|
|
23
|
+
finalStatuses: readonly string[];
|
|
24
|
+
/** Extra columns `insert` writes, after agent_id/status/started_at/updated_at. */
|
|
25
|
+
insertColumns: readonly string[];
|
|
26
|
+
/** Extra columns `update` may set, besides status and error. */
|
|
27
|
+
writableColumns: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JobState {
|
|
31
|
+
insert(agentId: string, ...values: (string | number | null)[]): number;
|
|
32
|
+
update(id: number, fields: Record<string, string | null | undefined>): void;
|
|
33
|
+
latest<T>(agentId: string): T | null;
|
|
34
|
+
isActive(agentId: string): boolean;
|
|
35
|
+
activeAgentIds(): Set<string>;
|
|
36
|
+
markInterrupted(): number;
|
|
37
|
+
/** Delete all but the newest `keepPerAgent` rows of each agent. Returns the count. */
|
|
38
|
+
prune(keepPerAgent: number): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createJobState(spec: JobStateSpec): JobState {
|
|
42
|
+
const { table, initialStatus, activeStatuses, finalStatuses } = spec;
|
|
43
|
+
const insertColumns = ['agent_id', 'status', ...spec.insertColumns, 'started_at', 'updated_at'];
|
|
44
|
+
const writable = new Set(['status', 'error', ...spec.writableColumns]);
|
|
45
|
+
const activePlaceholders = activeStatuses.map(() => '?').join(', ');
|
|
46
|
+
|
|
47
|
+
const latest = <T,>(agentId: string): T | null => {
|
|
48
|
+
const db = openDb(true);
|
|
49
|
+
try {
|
|
50
|
+
return (db.prepare(`SELECT * FROM ${table} WHERE agent_id = ? ORDER BY id DESC LIMIT 1`).get(agentId) as T | undefined) ?? null;
|
|
51
|
+
} finally {
|
|
52
|
+
db.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
insert(agentId, ...values) {
|
|
58
|
+
if (values.length !== spec.insertColumns.length) {
|
|
59
|
+
throw new Error(`${table}: insert expects ${spec.insertColumns.length} value(s), got ${values.length}`);
|
|
60
|
+
}
|
|
61
|
+
const db = openDb(false);
|
|
62
|
+
try {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const info = db.prepare(
|
|
65
|
+
`INSERT INTO ${table} (${insertColumns.join(', ')})
|
|
66
|
+
VALUES (${insertColumns.map(() => '?').join(', ')})`,
|
|
67
|
+
).run(agentId, initialStatus, ...values, now, now);
|
|
68
|
+
return Number(info.lastInsertRowid);
|
|
69
|
+
} finally {
|
|
70
|
+
db.close();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
update(id, fields) {
|
|
75
|
+
const entries = Object.entries(fields).filter(([key, value]) => value !== undefined && writable.has(key));
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const sets = [...entries.map(([key]) => `${key} = ?`), 'updated_at = ?'];
|
|
78
|
+
const values: unknown[] = [...entries.map(([, value]) => value), now];
|
|
79
|
+
const status = fields.status;
|
|
80
|
+
if (status && finalStatuses.includes(status)) {
|
|
81
|
+
sets.push('finished_at = ?');
|
|
82
|
+
values.push(now);
|
|
83
|
+
} else if (status) {
|
|
84
|
+
sets.push('finished_at = NULL');
|
|
85
|
+
}
|
|
86
|
+
const db = openDb(false);
|
|
87
|
+
try {
|
|
88
|
+
db.prepare(`UPDATE ${table} SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
|
|
89
|
+
} finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
latest,
|
|
95
|
+
|
|
96
|
+
isActive(agentId) {
|
|
97
|
+
const row = latest<{ status: string }>(agentId);
|
|
98
|
+
return !!row && activeStatuses.includes(row.status);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
activeAgentIds() {
|
|
102
|
+
const db = openDb(true);
|
|
103
|
+
try {
|
|
104
|
+
const rows = db.prepare(
|
|
105
|
+
`SELECT DISTINCT agent_id FROM ${table} WHERE status IN (${activePlaceholders})`,
|
|
106
|
+
).all(...activeStatuses) as { agent_id: string }[];
|
|
107
|
+
return new Set(rows.map((r) => r.agent_id));
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
markInterrupted() {
|
|
114
|
+
const db = openDb(false);
|
|
115
|
+
try {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const info = db.prepare(
|
|
118
|
+
`UPDATE ${table}
|
|
119
|
+
SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
|
|
120
|
+
status = 'interrupted', updated_at = ?, finished_at = ?
|
|
121
|
+
WHERE status IN (${activePlaceholders})`,
|
|
122
|
+
).run(now, now, ...activeStatuses);
|
|
123
|
+
return info.changes;
|
|
124
|
+
} finally {
|
|
125
|
+
db.close();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
prune(keepPerAgent) {
|
|
130
|
+
const db = openDb(false);
|
|
131
|
+
try {
|
|
132
|
+
const info = db.prepare(
|
|
133
|
+
`DELETE FROM ${table} WHERE id IN (
|
|
134
|
+
SELECT id FROM (
|
|
135
|
+
SELECT id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY id DESC) AS rn FROM ${table}
|
|
136
|
+
) WHERE rn > ?
|
|
137
|
+
)`,
|
|
138
|
+
).run(keepPerAgent);
|
|
139
|
+
return info.changes;
|
|
140
|
+
} finally {
|
|
141
|
+
db.close();
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention for the lifecycle job tables (`agent_upgrades`, `agent_recreates`,
|
|
3
|
+
* `agent_restores`, `agent_edits`). They record every update, recreate, restore and edit
|
|
4
|
+
* an agent has been through, and nothing pruned them: the rows grew for the life of the
|
|
5
|
+
* installation while the panel only ever reads the latest one of each agent. Each table
|
|
6
|
+
* keeps the newest `KEEP_PER_AGENT` rows per agent; the rest is history.
|
|
7
|
+
*
|
|
8
|
+
* Best-effort: a failure is logged and does not stop the others or the startup.
|
|
9
|
+
*/
|
|
10
|
+
import { pruneUpdates } from '@/lib/agent-update-state';
|
|
11
|
+
import { pruneRecreates } from '@/lib/agent-recreate-state';
|
|
12
|
+
import { pruneRestores } from '@/lib/agent-restore-state';
|
|
13
|
+
import { pruneEdits } from '@/lib/agent-edit-state';
|
|
14
|
+
|
|
15
|
+
/** Rows kept per agent, per table. Enough to see the recent history of an agent. */
|
|
16
|
+
const KEEP_PER_AGENT = 20;
|
|
17
|
+
|
|
18
|
+
export function pruneLifecycleRows(keepPerAgent: number = KEEP_PER_AGENT): { table: string; removed: number }[] {
|
|
19
|
+
const prunes: [string, (keep: number) => number][] = [
|
|
20
|
+
['agent_upgrades', pruneUpdates],
|
|
21
|
+
['agent_recreates', pruneRecreates],
|
|
22
|
+
['agent_restores', pruneRestores],
|
|
23
|
+
['agent_edits', pruneEdits],
|
|
24
|
+
];
|
|
25
|
+
const result: { table: string; removed: number }[] = [];
|
|
26
|
+
for (const [table, prune] of prunes) {
|
|
27
|
+
try {
|
|
28
|
+
result.push({ table, removed: prune(keepPerAgent) });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.warn(`[rev4a] could not prune ${table}: ${(e as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/lib/agent-restore.ts
CHANGED
|
@@ -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.
|
|
127
|
-
*
|
|
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
|
|
135
|
-
const name =
|
|
136
|
-
if (
|
|
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
|
+
}
|