@anhlp/alp-core 0.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/adapter.d.ts +20 -0
- package/dist/adapter.js +30 -0
- package/dist/atomic.d.ts +2 -0
- package/dist/atomic.js +28 -0
- package/dist/backup.d.ts +27 -0
- package/dist/backup.js +582 -0
- package/dist/beads.d.ts +65 -0
- package/dist/beads.js +213 -0
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +32 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +11 -0
- package/dist/formulas.d.ts +25 -0
- package/dist/formulas.js +201 -0
- package/dist/hook-run.d.ts +44 -0
- package/dist/hook-run.js +60 -0
- package/dist/hooks.d.ts +35 -0
- package/dist/hooks.js +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +63 -0
- package/dist/init.d.ts +30 -0
- package/dist/init.js +88 -0
- package/dist/library-edit.d.ts +90 -0
- package/dist/library-edit.js +315 -0
- package/dist/library.d.ts +28 -0
- package/dist/library.js +108 -0
- package/dist/log.d.ts +37 -0
- package/dist/log.js +156 -0
- package/dist/migrations.d.ts +9 -0
- package/dist/migrations.js +95 -0
- package/dist/models.d.ts +11 -0
- package/dist/models.js +44 -0
- package/dist/permissions.d.ts +14 -0
- package/dist/permissions.js +229 -0
- package/dist/profiles.d.ts +27 -0
- package/dist/profiles.js +122 -0
- package/dist/promptsafe.d.ts +1 -0
- package/dist/promptsafe.js +13 -0
- package/dist/providers.d.ts +90 -0
- package/dist/providers.js +101 -0
- package/dist/resolver.d.ts +41 -0
- package/dist/resolver.js +240 -0
- package/dist/safe-path.d.ts +2 -0
- package/dist/safe-path.js +24 -0
- package/dist/shell.d.ts +16 -0
- package/dist/shell.js +22 -0
- package/dist/state-schema.d.ts +9 -0
- package/dist/state-schema.js +54 -0
- package/dist/task-rows.d.ts +47 -0
- package/dist/task-rows.js +45 -0
- package/dist/tasks.d.ts +128 -0
- package/dist/tasks.js +835 -0
- package/dist/teams.d.ts +69 -0
- package/dist/teams.js +159 -0
- package/dist/template-path.d.ts +2 -0
- package/dist/template-path.js +10 -0
- package/dist/trust.d.ts +8 -0
- package/dist/trust.js +47 -0
- package/dist/types.d.ts +51 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/upgrade.js +200 -0
- package/dist/user-settings.d.ts +6 -0
- package/dist/user-settings.js +72 -0
- package/dist/validation.d.ts +17 -0
- package/dist/validation.js +295 -0
- package/dist/verify.d.ts +15 -0
- package/dist/verify.js +123 -0
- package/dist/workflow.d.ts +14 -0
- package/dist/workflow.js +24 -0
- package/package.json +309 -0
- package/src/adapter.d.ts +20 -0
- package/src/adapter.js +29 -0
- package/src/atomic.d.ts +2 -0
- package/src/atomic.js +28 -0
- package/src/backup.d.ts +27 -0
- package/src/backup.js +570 -0
- package/src/beads.js +236 -0
- package/src/delegation.js +28 -0
- package/src/errors.js +7 -0
- package/src/formulas.d.ts +25 -0
- package/src/formulas.js +202 -0
- package/src/hook-run.js +57 -0
- package/src/hooks.js +36 -0
- package/src/index.js +31 -0
- package/src/init.js +92 -0
- package/src/library-edit.js +346 -0
- package/src/library.js +117 -0
- package/src/log.d.ts +37 -0
- package/src/log.js +177 -0
- package/src/migrations.d.ts +9 -0
- package/src/migrations.js +111 -0
- package/src/models.js +45 -0
- package/src/permissions.d.ts +14 -0
- package/src/permissions.js +242 -0
- package/src/profiles.js +139 -0
- package/src/promptsafe.d.ts +1 -0
- package/src/promptsafe.js +19 -0
- package/src/providers.js +122 -0
- package/src/resolver.js +238 -0
- package/src/safe-path.js +20 -0
- package/src/shell.js +28 -0
- package/src/state-schema.d.ts +9 -0
- package/src/state-schema.js +60 -0
- package/src/task-rows.js +44 -0
- package/src/tasks.d.ts +128 -0
- package/src/tasks.js +881 -0
- package/src/teams.js +170 -0
- package/src/template-path.js +8 -0
- package/src/trust.js +59 -0
- package/src/types.d.ts +51 -0
- package/src/upgrade.js +195 -0
- package/src/user-settings.d.ts +6 -0
- package/src/user-settings.js +76 -0
- package/src/validation.d.ts +17 -0
- package/src/validation.js +302 -0
- package/src/verify.d.ts +15 -0
- package/src/verify.js +127 -0
- package/src/workflow.js +28 -0
- package/templates/ALP.md +52 -0
- package/templates/agents/lead/AGENT.md +90 -0
- package/templates/agents/main/AGENT.md +150 -0
- package/templates/agents/oracle/AGENT.md +44 -0
- package/templates/agents/peer/AGENT.md +55 -0
- package/templates/agents/reviewer/AGENT.md +80 -0
- package/templates/agents/supervisor/AGENT.md +72 -0
- package/templates/providers/antigravity.json +6 -0
- package/templates/providers/opencode.json +7 -0
- package/templates/role-skills.json +8 -0
- package/templates/skills/bug-loop/SKILL.md +44 -0
- package/templates/skills/bug-loop/references/test-proof.md +25 -0
- package/templates/skills/goal-griller/SKILL.md +49 -0
- package/templates/skills/prompt-leverage/SKILL.md +37 -0
- package/templates/skills/prompt-leverage/references/brief-template.md +27 -0
- package/templates/skills/sequence-execution-plan/SKILL.md +42 -0
- package/templates/skills/sequence-execution-plan/references/plan-template.md +22 -0
- package/templates/skills/smart-commits/SKILL.md +40 -0
- package/templates/skills/xia/SKILL.md +42 -0
- package/templates/teams/cafe/HOUSE_RULES.md +1 -0
- package/templates/teams/cafe/team.json +15 -0
- package/templates/teams/pho/HOUSE_RULES.md +1 -0
- package/templates/teams/pho/team.json +14 -0
package/src/backup.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, open, readdir, readFile, readlink, rm, rmdir, stat, symlink, utimes } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createGunzip, createGzip } from 'node:zlib';
|
|
9
|
+
import { AlpError } from './errors.js';
|
|
10
|
+
import { moveFile, writeAtomic } from './atomic.js';
|
|
11
|
+
import { STATE_SCHEMA, assertStateSupported, readStateSchema } from './state-schema.js';
|
|
12
|
+
import { withTaskLock } from './tasks.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Backups of ALP's data and their restore (plans/reference/ALPD.md §68).
|
|
16
|
+
*
|
|
17
|
+
* A backup is one gzip file of JSON lines: a header naming the units it holds, one entry per
|
|
18
|
+
* directory, link and file (file content in base64 chunks with its size and sha256), and an
|
|
19
|
+
* end line with the count and a sha256 over every line before it. A unit is a file or a
|
|
20
|
+
* directory ALP owns, recorded as present or absent; a restore makes each unit match the
|
|
21
|
+
* backup exactly. What is not a unit (logs, worktrees, the lock, live work, secrets of the
|
|
22
|
+
* relay) is never backed up and never touched by a restore.
|
|
23
|
+
*
|
|
24
|
+
* Restore validates the whole backup while it extracts it into staging directories beside
|
|
25
|
+
* the targets; a corrupt, cut-short or unsupported backup is refused before anything is
|
|
26
|
+
* replaced. Then a journal ($ALP_HOME/restore-journal.json) lists every swap, the current
|
|
27
|
+
* data is moved aside, the staged data moved in, and the journal marked committed. A
|
|
28
|
+
* restore that fails, or whose process dies, before the commit is rolled back by the next
|
|
29
|
+
* restore or alpd start; one past the commit is finished. Before swapping, the current data
|
|
30
|
+
* is saved to an automatic backup, so a completed restore can be undone too.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const BACKUP_FORMAT = 1;
|
|
34
|
+
export const BACKUP_EXTENSION = '.alpbackup';
|
|
35
|
+
|
|
36
|
+
/** alpd's state: sessions, their timelines and receipts, and the runtime's durable state. */
|
|
37
|
+
export const STATE_UNITS = ['state/schema.json', 'state/sessions', 'state/timeline', 'state/receipts', 'state/recall.json', 'state/pause.json', 'state/trust.json', 'state/web-workspaces.json'];
|
|
38
|
+
/**
|
|
39
|
+
* Live work (running assignments alpd continues after a restart) and the journal of an
|
|
40
|
+
* unfinished migration are never backed up: a restore of the state removes them, so restored
|
|
41
|
+
* sessions never continue work of another time and no migration resumes over restored data.
|
|
42
|
+
*/
|
|
43
|
+
export const CLEARED_UNITS = ['state/live.json', 'state/migration.json'];
|
|
44
|
+
/** Everything a backup of $ALP_HOME holds: the state, the user's settings and library, boards and assignment logs. */
|
|
45
|
+
export const HOME_UNITS = [
|
|
46
|
+
...STATE_UNITS,
|
|
47
|
+
'settings.json', 'library.json', 'role-skills.json', 'lessons.md',
|
|
48
|
+
'agents', 'skills', 'teams', 'hooks', 'mcp', 'providers', 'formulas',
|
|
49
|
+
'boards', 'runs',
|
|
50
|
+
];
|
|
51
|
+
/** A project's ALP files: instructions, settings, its library layer and its tasks. */
|
|
52
|
+
export const PROJECT_UNITS = ['ALP.md', '.alp/settings.json', '.alp/lessons.md', '.alp/tasks', '.alp/agents', '.alp/skills', '.alp/teams', '.alp/hooks', '.alp/mcp', '.alp/formulas'];
|
|
53
|
+
|
|
54
|
+
const CHUNK = 1024 * 1024;
|
|
55
|
+
const AUTOMATIC_KEEP = 5;
|
|
56
|
+
const JOURNAL = 'restore-journal.json';
|
|
57
|
+
const RESTORED = path.join('state', 'restored.json');
|
|
58
|
+
/** Never copied out of a unit: temporary files, the task lock, and what an interrupted restore left. */
|
|
59
|
+
const transient = name => name.endsWith('.tmp') || name === '.lock' || /\.pre-restore-[0-9a-f]+$/.test(name) || /^\.restore-[0-9a-f]+$/.test(name);
|
|
60
|
+
|
|
61
|
+
export const backupsDir = home => path.join(home, 'backups');
|
|
62
|
+
const journalFile = home => path.join(home, JOURNAL);
|
|
63
|
+
const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
64
|
+
const exists = file => lstat(file).then(() => true, () => false);
|
|
65
|
+
const invalid = (message, options) => new AlpError('BACKUP_INVALID', message, options);
|
|
66
|
+
|
|
67
|
+
/** A unit path or an entry path inside one: relative, forward slashes, no `..`, no empty parts. */
|
|
68
|
+
function safeRelative(value) {
|
|
69
|
+
return typeof value === 'string' && value.length > 0 && value.length < 4096 && !value.startsWith('/') && !/^[A-Za-z]:/.test(value)
|
|
70
|
+
&& !value.includes('\\') && !value.includes('\0') && value.split('/').every(part => part && part !== '.' && part !== '..');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Session ids and project roots the state's records name, read without the store. */
|
|
74
|
+
async function sessionRecords(home) {
|
|
75
|
+
const directory = path.join(home, 'state', 'sessions');
|
|
76
|
+
const records = [];
|
|
77
|
+
for (const name of await readdir(directory).catch(() => [])) {
|
|
78
|
+
if (!name.endsWith('.json')) continue;
|
|
79
|
+
try { records.push(JSON.parse(await readFile(path.join(directory, name), 'utf8'))); } catch {}
|
|
80
|
+
}
|
|
81
|
+
return records;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Project roots of the recorded sessions that still exist and hold ALP files. */
|
|
85
|
+
export async function knownProjects(home) {
|
|
86
|
+
const roots = new Set();
|
|
87
|
+
for (const record of await sessionRecords(home)) {
|
|
88
|
+
const root = record?.session?.projectRoot ?? record?.spec?.cwd;
|
|
89
|
+
if (typeof root === 'string' && path.isAbsolute(root)) roots.add(path.resolve(root));
|
|
90
|
+
}
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const root of [...roots].sort()) if (await exists(path.join(root, '.alp'))) found.push(root);
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Native threads the sessions resume from: data the provider keeps, which ALP cannot back up. */
|
|
97
|
+
async function externalReferences(home) {
|
|
98
|
+
const references = [];
|
|
99
|
+
for (const record of await sessionRecords(home)) {
|
|
100
|
+
const session = record?.session;
|
|
101
|
+
if (session?.threadId) references.push({ sessionId: record.id, runtime: session.runtime, threadId: session.threadId, projectRoot: session.projectRoot });
|
|
102
|
+
}
|
|
103
|
+
return references;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Walks one unit; yields its entries. Symbolic links are recorded, never followed. */
|
|
107
|
+
async function* unitEntries(base, unit) {
|
|
108
|
+
const full = path.join(base, unit);
|
|
109
|
+
const info = await lstat(full).catch(error => { if (error?.code === 'ENOENT') return undefined; throw error; });
|
|
110
|
+
if (!info) return;
|
|
111
|
+
async function* walk(relative, info) {
|
|
112
|
+
const file = path.join(base, relative);
|
|
113
|
+
if (info.isSymbolicLink()) yield { t: 'link', path: relative, target: await readlink(file) };
|
|
114
|
+
else if (info.isDirectory()) {
|
|
115
|
+
yield { t: 'dir', path: relative, mode: info.mode & 0o777 };
|
|
116
|
+
for (const name of (await readdir(file)).sort()) {
|
|
117
|
+
if (transient(name)) continue;
|
|
118
|
+
const child = await lstat(path.join(file, name)).catch(() => undefined);
|
|
119
|
+
if (child) yield* walk(`${relative}/${name}`, child);
|
|
120
|
+
}
|
|
121
|
+
} else if (info.isFile()) yield { t: 'file', path: relative, mode: info.mode & 0o777, file };
|
|
122
|
+
}
|
|
123
|
+
yield* walk(unit, info);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function unitKind(base, unit) {
|
|
127
|
+
const info = await lstat(path.join(base, unit)).catch(error => { if (error?.code === 'ENOENT') return undefined; throw error; });
|
|
128
|
+
return !info ? 'absent' : info.isDirectory() ? 'dir' : info.isSymbolicLink() ? 'link' : 'file';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Writes a backup of `home`'s units and of each project's units to `out`. The caller holds
|
|
133
|
+
* whatever keeps writers away (alpd stopped, its lock taken); each project's tasks are read
|
|
134
|
+
* under the task lock. The file appears only once it is complete.
|
|
135
|
+
* @param {{ home: string, out: string, units?: string[], projects?: string[], by?: string, kind?: string }} options
|
|
136
|
+
*/
|
|
137
|
+
export async function createBackup({ home, out, units = HOME_UNITS, projects = [], by = 'alp', kind = 'manual' }) {
|
|
138
|
+
// Each project's tasks stay locked for the whole backup, so the backup holds one moment of them.
|
|
139
|
+
return holdTaskLocks(projects.map(root => path.resolve(root)), () => writeBackup({ home, out, units, projects, by, kind }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function writeBackup({ home, out, units, projects, by, kind }) {
|
|
143
|
+
const withState = units.some(unit => unit.startsWith('state/'));
|
|
144
|
+
// The schema the state is in; a state of unknown schema is still backed up, as such.
|
|
145
|
+
const stateSchema = withState ? await readStateSchema(home).then(info => info.schema, () => null) : null;
|
|
146
|
+
const header = {
|
|
147
|
+
t: 'header', format: 'alp-backup', version: BACKUP_FORMAT, kind, by, createdAt: new Date().toISOString(), home,
|
|
148
|
+
stateSchema,
|
|
149
|
+
units: await Promise.all(units.map(async unit => ({ path: unit, kind: await unitKind(home, unit) }))),
|
|
150
|
+
projects: await Promise.all(projects.map(async root => ({ root: path.resolve(root), units: await Promise.all(PROJECT_UNITS.map(async unit => ({ path: unit, kind: await unitKind(root, unit) }))) }))),
|
|
151
|
+
external: withState ? await externalReferences(home) : [],
|
|
152
|
+
};
|
|
153
|
+
const digest = createHash('sha256');
|
|
154
|
+
const summary = { file: out, files: 0, bytes: 0, projects: header.projects.map(project => project.root), stateSchema, sessions: 0, tasks: 0 };
|
|
155
|
+
const line = record => { const text = `${JSON.stringify(record)}\n`; digest.update(text); return text; };
|
|
156
|
+
|
|
157
|
+
async function* area(name, base, unitList) {
|
|
158
|
+
for (const { path: unit, kind } of unitList) {
|
|
159
|
+
if (kind === 'absent') continue;
|
|
160
|
+
for await (const entry of unitEntries(base, unit)) {
|
|
161
|
+
if (entry.t !== 'file') { yield line({ ...entry, area: name }); continue; }
|
|
162
|
+
const content = await readFile(entry.file);
|
|
163
|
+
summary.files++;
|
|
164
|
+
summary.bytes += content.length;
|
|
165
|
+
if (name === 'home' && /^state\/sessions\/[^/]+\.json$/.test(entry.path)) summary.sessions++;
|
|
166
|
+
if (name !== 'home' && /^\.alp\/tasks\/[^/]+\.json$/.test(entry.path)) summary.tasks++;
|
|
167
|
+
const chunks = Math.max(1, Math.ceil(content.length / CHUNK));
|
|
168
|
+
yield line({ t: 'file', area: name, path: entry.path, mode: entry.mode, size: content.length, sha256: createHash('sha256').update(content).digest('hex'), chunks });
|
|
169
|
+
for (let index = 0; index < chunks; index++) yield line({ t: 'data', b: content.subarray(index * CHUNK, (index + 1) * CHUNK).toString('base64') });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function* lines() {
|
|
175
|
+
yield line(header);
|
|
176
|
+
yield* area('home', home, header.units);
|
|
177
|
+
for (const [index, project] of header.projects.entries()) yield* area(`project:${index}`, project.root, project.units);
|
|
178
|
+
yield `${JSON.stringify({ t: 'end', files: summary.files, bytes: summary.bytes, sha256: digest.digest('hex') })}\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await mkdir(path.dirname(out), { recursive: true, mode: 0o700 });
|
|
182
|
+
const partial = `${out}.${randomBytes(4).toString('hex')}.partial`;
|
|
183
|
+
try {
|
|
184
|
+
await pipeline(Readable.from(lines()), createGzip(), createWriteStream(partial, { mode: 0o600 }));
|
|
185
|
+
await moveFile(partial, out);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await rm(partial, { force: true }).catch(() => {});
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** An automatic backup in $ALP_HOME/backups (before a migration or a restore); only the newest few of a kind are kept. */
|
|
194
|
+
export async function automaticBackup(home, kind, { units = HOME_UNITS, projects = [], by } = {}) {
|
|
195
|
+
const directory = backupsDir(home);
|
|
196
|
+
const out = path.join(directory, `${kind}-${stamp()}-${randomBytes(2).toString('hex')}${BACKUP_EXTENSION}`);
|
|
197
|
+
await createBackup({ home, out, units, projects, by, kind });
|
|
198
|
+
const prefix = `${kind}-`;
|
|
199
|
+
const same = (await readdir(directory)).filter(name => name.startsWith(prefix) && name.endsWith(BACKUP_EXTENSION)).sort();
|
|
200
|
+
for (const name of same.slice(0, Math.max(0, same.length - AUTOMATIC_KEEP))) await rm(path.join(directory, name), { force: true });
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reads a backup line by line, checking each line, each file's size and sha256 and the end
|
|
206
|
+
* line, and hands each verified entry to `visit`. Throws BACKUP_INVALID or BACKUP_UNSUPPORTED.
|
|
207
|
+
*/
|
|
208
|
+
async function readBackup(file, { onHeader, visit }) {
|
|
209
|
+
if (!await stat(file).then(info => info.isFile(), () => false)) throw new AlpError('BACKUP_NOT_FOUND', `${file} is not a backup file`);
|
|
210
|
+
const digest = createHash('sha256');
|
|
211
|
+
let header;
|
|
212
|
+
let end;
|
|
213
|
+
let pending; // the file entry whose data lines are being read
|
|
214
|
+
let files = 0;
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
let lineNumber = 0;
|
|
217
|
+
const written = new Map(); // area → paths and links seen
|
|
218
|
+
async function take(text) {
|
|
219
|
+
lineNumber++;
|
|
220
|
+
if (end) throw invalid(`line ${lineNumber} follows the end of the backup`);
|
|
221
|
+
let record;
|
|
222
|
+
try { record = JSON.parse(text); } catch { throw invalid(`line ${lineNumber} is not JSON`); }
|
|
223
|
+
if (record?.t === 'end') { end = record; return; }
|
|
224
|
+
digest.update(`${text}\n`);
|
|
225
|
+
if (!header) {
|
|
226
|
+
if (record?.t !== 'header' || record.format !== 'alp-backup') throw invalid('it does not start with an ALP backup header');
|
|
227
|
+
if (record.version !== BACKUP_FORMAT) throw new AlpError('BACKUP_UNSUPPORTED', `backup format ${record.version} is not one this ALP reads (${BACKUP_FORMAT}); restore it with the ALP that made it, or a newer one`);
|
|
228
|
+
if (record.stateSchema !== null && record.stateSchema !== undefined && !(Number.isSafeInteger(record.stateSchema) && record.stateSchema >= 1)) throw invalid('its header names no valid state schema');
|
|
229
|
+
if (Number.isSafeInteger(record.stateSchema) && record.stateSchema > STATE_SCHEMA) throw new AlpError('BACKUP_SCHEMA_TOO_NEW', `the backup holds state schema ${record.stateSchema}${record.by ? ` from ${record.by}` : ''}; this ALP knows schema ${STATE_SCHEMA} at most. Restore it with that ALP version or a newer one`);
|
|
230
|
+
if (!Array.isArray(record.units) || !Array.isArray(record.projects) || ![...record.units, ...record.projects.flatMap(project => project?.units ?? [])].every(unit => safeRelative(unit?.path) && ['file', 'dir', 'link', 'absent'].includes(unit.kind))) throw invalid('its header lists units ALP cannot use');
|
|
231
|
+
if (!record.projects.every(project => typeof project?.root === 'string' && path.isAbsolute(project.root) && Array.isArray(project.units))) throw invalid('its header lists projects without an absolute root');
|
|
232
|
+
// Each unit once, none inside another: a restore swaps every unit on its own.
|
|
233
|
+
for (const list of [record.units, ...record.projects.map(project => project.units)]) {
|
|
234
|
+
const paths = list.map(unit => unit.path);
|
|
235
|
+
if (paths.some((one, index) => paths.some((other, at) => at !== index && (one === other || one.startsWith(`${other}/`))))) throw invalid('its header lists a unit twice, or one inside another');
|
|
236
|
+
}
|
|
237
|
+
header = record;
|
|
238
|
+
await onHeader?.(header);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (pending) {
|
|
242
|
+
if (record?.t !== 'data' || typeof record.b !== 'string') throw invalid(`${pending.entry.path} is cut short`);
|
|
243
|
+
const chunk = Buffer.from(record.b, 'base64');
|
|
244
|
+
pending.hash.update(chunk);
|
|
245
|
+
pending.size += chunk.length;
|
|
246
|
+
await pending.write?.(chunk);
|
|
247
|
+
if (--pending.left > 0) return;
|
|
248
|
+
const done = pending;
|
|
249
|
+
pending = undefined;
|
|
250
|
+
await done.close();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const area = record?.area;
|
|
254
|
+
const units = area === 'home' ? header.units : /^project:\d+$/.test(area ?? '') ? header.projects[Number(area.slice(8))]?.units : undefined;
|
|
255
|
+
if (!units) throw invalid(`line ${lineNumber} belongs to no area of the backup`);
|
|
256
|
+
if (!safeRelative(record.path)) throw invalid(`line ${lineNumber} names an unsafe path`);
|
|
257
|
+
const unit = units.find(candidate => record.path === candidate.path || record.path.startsWith(`${candidate.path}/`));
|
|
258
|
+
if (!unit || unit.kind === 'absent' || (record.path !== unit.path && unit.kind !== 'dir')) throw invalid(`${record.path} lies outside the units the backup declares`);
|
|
259
|
+
// Nothing is written through a link the backup holds, and no path comes twice.
|
|
260
|
+
const seen = written.get(area) ?? written.set(area, { paths: new Set(), links: [] }).get(area);
|
|
261
|
+
if (seen.paths.has(record.path) || seen.links.some(link => record.path.startsWith(`${link}/`))) throw invalid(`${record.path} comes twice, or lies under a link`);
|
|
262
|
+
seen.paths.add(record.path);
|
|
263
|
+
if (record.t === 'link') seen.links.push(record.path);
|
|
264
|
+
if (record.t === 'dir' || record.t === 'link') {
|
|
265
|
+
if (record.t === 'link' && typeof record.target !== 'string') throw invalid(`link ${record.path} has no target`);
|
|
266
|
+
await visit({ ...record, unit: unit.path });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (record.t !== 'file' || !Number.isSafeInteger(record.size) || record.size < 0 || typeof record.sha256 !== 'string' || !Number.isSafeInteger(record.chunks) || record.chunks < 1) throw invalid(`line ${lineNumber} is not a backup entry`);
|
|
270
|
+
files++;
|
|
271
|
+
bytes += record.size;
|
|
272
|
+
const sink = await visit({ ...record, unit: unit.path });
|
|
273
|
+
const hash = createHash('sha256');
|
|
274
|
+
const state = {
|
|
275
|
+
entry: record, hash, size: 0, left: record.chunks, write: sink?.write, abort: sink?.abort,
|
|
276
|
+
async close() {
|
|
277
|
+
await sink?.close?.();
|
|
278
|
+
if (state.size !== record.size || hash.digest('hex') !== record.sha256) throw invalid(`${record.path} does not match its checksum`);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
pending = state;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let buffered = '';
|
|
285
|
+
const decoder = new StringDecoder('utf8');
|
|
286
|
+
let refused;
|
|
287
|
+
try {
|
|
288
|
+
await pipeline(createReadStream(file), createGunzip(), async function (source) {
|
|
289
|
+
for await (const chunk of source) {
|
|
290
|
+
buffered += decoder.write(chunk);
|
|
291
|
+
let newline;
|
|
292
|
+
while ((newline = buffered.indexOf('\n')) >= 0) {
|
|
293
|
+
const text = buffered.slice(0, newline);
|
|
294
|
+
buffered = buffered.slice(newline + 1);
|
|
295
|
+
if (text) await take(text).catch(error => { refused ??= error; throw error; });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await pending?.abort?.();
|
|
301
|
+
// Some Node versions report the stream's own close in place of what the check threw.
|
|
302
|
+
if (refused) throw refused;
|
|
303
|
+
if (error instanceof AlpError) throw error;
|
|
304
|
+
throw invalid(`it cannot be read (${error?.code ?? error?.message ?? error})`, { cause: error });
|
|
305
|
+
}
|
|
306
|
+
buffered += decoder.end();
|
|
307
|
+
if (pending) await pending.abort?.();
|
|
308
|
+
if (buffered) throw invalid('its last line is cut short');
|
|
309
|
+
if (!header) throw invalid('it is empty');
|
|
310
|
+
if (pending) throw invalid(`${pending.entry.path} is cut short`);
|
|
311
|
+
if (!end) throw invalid('it has no end: the file was cut short');
|
|
312
|
+
if (end.files !== files || end.bytes !== bytes || end.sha256 !== digest.digest('hex')) throw invalid('its end does not match its content');
|
|
313
|
+
return { header, files, bytes };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Checks a backup end to end without writing anything; says what it holds.
|
|
318
|
+
* @param {string} file
|
|
319
|
+
*/
|
|
320
|
+
export async function verifyBackup(file) {
|
|
321
|
+
let sessions = 0;
|
|
322
|
+
let tasks = 0;
|
|
323
|
+
const { header, files, bytes } = await readBackup(file, {
|
|
324
|
+
visit(entry) {
|
|
325
|
+
if (entry.t !== 'file') return;
|
|
326
|
+
if (entry.area === 'home' && /^state\/sessions\/[^/]+\.json$/.test(entry.path)) sessions++;
|
|
327
|
+
if (entry.area !== 'home' && /^\.alp\/tasks\/[^/]+\.json$/.test(entry.path)) tasks++;
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
file, kind: header.kind, by: header.by, createdAt: header.createdAt, home: header.home, stateSchema: header.stateSchema,
|
|
332
|
+
units: header.units.filter(unit => unit.kind !== 'absent').map(unit => unit.path),
|
|
333
|
+
projects: header.projects.map(project => project.root),
|
|
334
|
+
sessions, tasks, files, bytes, external: header.external?.length ?? 0,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Moves the data of an unfinished restore back, or finishes one past its commit. */
|
|
339
|
+
async function settleJournal(journal, { releaseLocks = false } = {}) {
|
|
340
|
+
if (journal.phase === 'committed') {
|
|
341
|
+
for (const unit of journal.units) await rm(unit.aside, { recursive: true, force: true });
|
|
342
|
+
} else {
|
|
343
|
+
for (const unit of [...journal.units].reverse()) {
|
|
344
|
+
if (await exists(unit.aside)) {
|
|
345
|
+
await rm(unit.target, { recursive: true, force: true });
|
|
346
|
+
await moveFile(unit.aside, unit.target);
|
|
347
|
+
} else if (!unit.existed && unit.staged && !await exists(unit.staged)) {
|
|
348
|
+
// Nothing stood there before; what stands there now came from the backup.
|
|
349
|
+
await rm(unit.target, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (journal.marker) await rm(journal.marker, { force: true });
|
|
353
|
+
}
|
|
354
|
+
for (const directory of journal.staging) await rm(directory, { recursive: true, force: true });
|
|
355
|
+
// A project's .alp the restore created for its staging goes again when nothing else went in.
|
|
356
|
+
for (const directory of journal.created ?? []) await rmdir(directory).catch(() => {});
|
|
357
|
+
// Task locks the restore held: its process is gone, and the caller holds alpd's lock, so no other restore runs.
|
|
358
|
+
if (releaseLocks) for (const lock of journal.locks ?? []) await rmdir(lock).catch(() => {});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Settles a restore that stopped part way: rolled back before its commit, finished after it.
|
|
363
|
+
* alpd and every restore call this first, so no one works on half-restored data.
|
|
364
|
+
* @param {string} home
|
|
365
|
+
* @returns {Promise<{ outcome: 'rolled back' | 'completed', file?: string, safety?: string } | undefined>}
|
|
366
|
+
*/
|
|
367
|
+
export async function recoverInterruptedRestore(home) {
|
|
368
|
+
let journal;
|
|
369
|
+
try { journal = JSON.parse(await readFile(journalFile(home), 'utf8')); }
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
372
|
+
throw new AlpError('RESTORE_JOURNAL_UNREADABLE', `${journalFile(home)} records an interrupted restore but cannot be read; see docs/alp/data-recovery.md before starting ALP`, { cause: error });
|
|
373
|
+
}
|
|
374
|
+
// A journal another ALP wrote in a form this one does not know is left for that ALP.
|
|
375
|
+
if (journal?.version !== undefined && journal.version !== 1) throw new AlpError('RESTORE_JOURNAL_UNREADABLE', `${journalFile(home)} records a restore by another ALP version (journal ${journal.version}); finish it with that version`);
|
|
376
|
+
if (!Array.isArray(journal?.units) || !Array.isArray(journal.staging)) throw new AlpError('RESTORE_JOURNAL_UNREADABLE', `${journalFile(home)} records an interrupted restore but does not say what moved; see docs/alp/data-recovery.md before starting ALP`);
|
|
377
|
+
await settleJournal(journal, { releaseLocks: true });
|
|
378
|
+
await rm(journalFile(home), { force: true });
|
|
379
|
+
return { outcome: journal.phase === 'committed' ? 'completed' : 'rolled back', file: journal.file, safety: journal.safety };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Holds the task locks of `roots` (those with tasks) while `work` runs. A writer takes a lock
|
|
384
|
+
* older than ten seconds for a stale one, so a held lock is touched every two seconds.
|
|
385
|
+
*/
|
|
386
|
+
async function holdTaskLocks(roots, work) {
|
|
387
|
+
const held = [];
|
|
388
|
+
const touch = setInterval(() => { const now = new Date(); for (const lock of held) void utimes(lock, now, now).catch(() => {}); }, 2000);
|
|
389
|
+
touch.unref?.();
|
|
390
|
+
const take = async ([first, ...rest]) => {
|
|
391
|
+
if (first === undefined) return work();
|
|
392
|
+
if (!await exists(path.join(first, '.alp', 'tasks'))) return take(rest);
|
|
393
|
+
return withTaskLock(first, () => { held.push(path.join(first, '.alp', 'tasks', '.lock')); return take(rest); });
|
|
394
|
+
};
|
|
395
|
+
try { return await take(roots); }
|
|
396
|
+
finally { clearInterval(touch); }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Restores a backup over `home` and, with `projects`, over the projects it holds whose
|
|
401
|
+
* roots exist. The current data is checked first: a home in a state schema newer than
|
|
402
|
+
* this ALP knows is refused unless `replaceNewer` says to replace it (it is saved first).
|
|
403
|
+
* The caller keeps alpd away (it holds alpd's lock). `clearLive: false` keeps live work
|
|
404
|
+
* and an unfinished migration's journal (a migration's rollback); `beforeSwap(index, total)` is a test hook, called before each swap and, with `index === total`, before the commit.
|
|
405
|
+
* @param {{ home: string, file: string, projects?: boolean, safety?: boolean, clearLive?: boolean, replaceNewer?: boolean, by?: string, beforeSwap?: (index: number, total: number) => Promise<void> | void }} options
|
|
406
|
+
*/
|
|
407
|
+
export async function restoreBackup({ home, file, projects = true, safety = true, clearLive = true, replaceNewer = false, by = 'alp', beforeSwap }) {
|
|
408
|
+
const interrupted = await recoverInterruptedRestore(home);
|
|
409
|
+
// An older ALP never writes over state a newer one made unless told to; an unreadable marker is what a restore repairs.
|
|
410
|
+
try { assertStateSupported(await readStateSchema(home), home); }
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error?.code !== 'STATE_SCHEMA_UNREADABLE' && !(error?.code === 'STATE_SCHEMA_TOO_NEW' && replaceNewer)) throw error;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const id = randomBytes(6).toString('hex');
|
|
416
|
+
const staging = new Map(); // area → staging root
|
|
417
|
+
const skipped = [];
|
|
418
|
+
let header;
|
|
419
|
+
const warnings = [];
|
|
420
|
+
const areaBase = area => area === 'home' ? home : header.projects[Number(area.slice(8))].root;
|
|
421
|
+
const directories = [];
|
|
422
|
+
// The journal exists from the first staged file on, so a crash at any point leaves nothing behind.
|
|
423
|
+
const journal = { version: 1, id, file: path.resolve(file), by, startedAt: new Date().toISOString(), phase: 'staging', units: [], staging: [], created: [], locks: [] };
|
|
424
|
+
const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + '\n');
|
|
425
|
+
const abandon = async () => { await settleJournal(journal); await rm(journalFile(home), { force: true }); };
|
|
426
|
+
try {
|
|
427
|
+
await readBackup(file, {
|
|
428
|
+
async onHeader(found) {
|
|
429
|
+
header = found;
|
|
430
|
+
staging.set('home', path.join(home, `.restore-${id}`));
|
|
431
|
+
for (const [index, project] of header.projects.entries()) {
|
|
432
|
+
if (!projects) continue;
|
|
433
|
+
if (!await stat(project.root).then(info => info.isDirectory(), () => false)) { skipped.push({ root: project.root, reason: 'the directory does not exist' }); continue; }
|
|
434
|
+
if (!await exists(path.join(project.root, '.alp'))) journal.created.push(path.join(project.root, '.alp'));
|
|
435
|
+
staging.set(`project:${index}`, path.join(project.root, '.alp', `.restore-${id}`));
|
|
436
|
+
}
|
|
437
|
+
journal.staging = [...staging.values()];
|
|
438
|
+
await record();
|
|
439
|
+
},
|
|
440
|
+
async visit(entry) {
|
|
441
|
+
const root = staging.get(entry.area);
|
|
442
|
+
if (!root) return undefined; // a skipped project: checked, not written
|
|
443
|
+
const target = path.join(root, ...entry.path.split('/'));
|
|
444
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
445
|
+
if (entry.t === 'dir') { await mkdir(target, { recursive: true, mode: 0o700 }); directories.push([target, entry.mode]); return undefined; }
|
|
446
|
+
if (entry.t === 'link') { await symlink(entry.target, target); return undefined; }
|
|
447
|
+
const handle = await open(target, 'wx', 0o600);
|
|
448
|
+
return {
|
|
449
|
+
write: chunk => handle.write(chunk).then(() => {}),
|
|
450
|
+
close: async () => { await handle.close(); await chmod(target, entry.mode & 0o777 || 0o600).catch(() => {}); },
|
|
451
|
+
abort: () => handle.close().catch(() => {}),
|
|
452
|
+
};
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
// Directory modes last, deepest first, once nothing more is written into them.
|
|
456
|
+
for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 0o777 | 0o700).catch(() => {});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
await abandon();
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// What the restored files say beyond their checksums: reported, not refused.
|
|
463
|
+
for (const [area, root] of staging) {
|
|
464
|
+
const base = area === 'home' ? 'state/sessions' : '.alp/tasks';
|
|
465
|
+
for (const name of await readdir(path.join(root, ...base.split('/'))).catch(() => [])) {
|
|
466
|
+
if (!name.endsWith('.json')) continue;
|
|
467
|
+
try { JSON.parse(await readFile(path.join(root, ...base.split('/'), name), 'utf8')); }
|
|
468
|
+
catch { warnings.push(`${area === 'home' ? '' : `${areaBase(area)}/`}${base}/${name} is not readable JSON; ALP skips it as before`); }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const plan = [];
|
|
473
|
+
for (const [area, root] of staging) {
|
|
474
|
+
const base = areaBase(area);
|
|
475
|
+
const units = area === 'home' ? header.units : header.projects[Number(area.slice(8))].units;
|
|
476
|
+
const known = area === 'home' ? HOME_UNITS : PROJECT_UNITS;
|
|
477
|
+
for (const unit of units) {
|
|
478
|
+
if (!known.includes(unit.path)) { warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`); continue; }
|
|
479
|
+
const target = path.join(base, ...unit.path.split('/'));
|
|
480
|
+
const staged = unit.kind === 'absent' ? undefined : path.join(root, ...unit.path.split('/'));
|
|
481
|
+
if (staged && !await exists(staged)) {
|
|
482
|
+
// A unit the backup declares present must have arrived in full.
|
|
483
|
+
await abandon();
|
|
484
|
+
throw invalid(`${unit.path} is declared but missing from the backup`);
|
|
485
|
+
}
|
|
486
|
+
plan.push({ area, unit: unit.path, target, staged, aside: `${target}.pre-restore-${id}`, existed: await exists(target) });
|
|
487
|
+
}
|
|
488
|
+
if (area === 'home' && clearLive && units.some(unit => unit.path === 'state/sessions')) {
|
|
489
|
+
for (const unit of CLEARED_UNITS) {
|
|
490
|
+
const target = path.join(base, ...unit.split('/'));
|
|
491
|
+
plan.push({ area, unit, target, staged: undefined, aside: `${target}.pre-restore-${id}`, existed: await exists(target) });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const roots = [...staging.keys()].filter(area => area !== 'home').map(areaBase);
|
|
497
|
+
let safetyFile;
|
|
498
|
+
if (safety) {
|
|
499
|
+
try {
|
|
500
|
+
safetyFile = await automaticBackup(home, 'pre-restore', { units: header.units.map(unit => unit.path).filter(unit => HOME_UNITS.includes(unit)), projects: roots, by });
|
|
501
|
+
} catch (error) {
|
|
502
|
+
await abandon();
|
|
503
|
+
throw new AlpError('RESTORE_SAFETY_BACKUP_FAILED', `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const locked = new Set();
|
|
508
|
+
for (const root of roots) if (await exists(path.join(root, '.alp', 'tasks'))) locked.add(root);
|
|
509
|
+
const marking = clearLive && plan.some(entry => entry.area === 'home' && entry.unit === 'state/sessions');
|
|
510
|
+
Object.assign(journal, { safety: safetyFile, phase: 'swapping', units: plan, locks: [...locked].map(root => path.join(root, '.alp', 'tasks', '.lock')), ...(marking ? { marker: path.join(home, RESTORED) } : {}) });
|
|
511
|
+
await record();
|
|
512
|
+
let failure;
|
|
513
|
+
try {
|
|
514
|
+
await holdTaskLocks(roots, async () => {
|
|
515
|
+
try {
|
|
516
|
+
for (const [index, entry] of plan.entries()) {
|
|
517
|
+
await beforeSwap?.(index, plan.length);
|
|
518
|
+
if (entry.existed) await moveFile(entry.target, entry.aside);
|
|
519
|
+
if (entry.staged) {
|
|
520
|
+
await mkdir(path.dirname(entry.target), { recursive: true, mode: 0o700 });
|
|
521
|
+
// Restored tasks stay locked until the task lock is let go, so no writer sees them half moved.
|
|
522
|
+
if (entry.unit === '.alp/tasks' && locked.has(areaBase(entry.area))) await mkdir(path.join(entry.staged, '.lock')).catch(() => {});
|
|
523
|
+
await moveFile(entry.staged, entry.target);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// A restored state does not start working by itself: alpd reopens nothing until asked (ALPD §68).
|
|
527
|
+
if (marking) {
|
|
528
|
+
await mkdir(path.join(home, 'state'), { recursive: true, mode: 0o700 });
|
|
529
|
+
await writeAtomic(path.join(home, RESTORED), JSON.stringify({ at: new Date().toISOString(), from: path.resolve(file), by }) + '\n');
|
|
530
|
+
}
|
|
531
|
+
await beforeSwap?.(plan.length, plan.length);
|
|
532
|
+
journal.phase = 'committed';
|
|
533
|
+
await record();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
// Rolled back while the task locks are still held, so no writer saves into data about to go.
|
|
536
|
+
try { await abandon(); }
|
|
537
|
+
catch (rollback) {
|
|
538
|
+
failure = new AlpError('RESTORE_ROLLBACK_FAILED', `restore failed (${error?.message ?? error}) and moving the data back failed (${rollback?.message ?? rollback}); ${journalFile(home)} records what is where, and the next alp restore or alpd start tries again`, { cause: error });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
failure = new AlpError('RESTORE_FAILED', `restore failed and was rolled back; the data is as it was: ${error?.message ?? error}`, { cause: error });
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
} catch (error) {
|
|
545
|
+
// The task locks could not be taken: nothing was moved yet.
|
|
546
|
+
if (journal.phase !== 'committed' && !failure) {
|
|
547
|
+
await abandon();
|
|
548
|
+
throw new AlpError('RESTORE_FAILED', `restore failed and was rolled back; the data is as it was: ${error?.message ?? error}`, { cause: error });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (failure) throw failure;
|
|
552
|
+
await settleJournal(journal);
|
|
553
|
+
await rm(journalFile(home), { force: true });
|
|
554
|
+
return {
|
|
555
|
+
file: path.resolve(file), safety: safetyFile, stateSchema: header.stateSchema, interrupted,
|
|
556
|
+
units: plan.map(entry => ({ area: entry.area === 'home' ? 'home' : areaBase(entry.area), unit: entry.unit, present: !!entry.staged })),
|
|
557
|
+
projects: roots, skipped, warnings,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** What a restore left for alpd's next start: open nothing by itself. Read at start, cleared once alpd is ready. */
|
|
562
|
+
export async function readRestoredMarker(home) {
|
|
563
|
+
const text = await readFile(path.join(home, RESTORED), 'utf8').catch(() => undefined);
|
|
564
|
+
if (text === undefined) return undefined;
|
|
565
|
+
try { return JSON.parse(text); } catch { return { at: undefined }; }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function clearRestoredMarker(home) {
|
|
569
|
+
await rm(path.join(home, RESTORED), { force: true });
|
|
570
|
+
}
|