syndai 0.5.0 → 0.7.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/CHANGELOG.md +55 -0
- package/README.md +43 -5
- package/package.json +1 -1
- package/src/auth.js +44 -0
- package/src/changes.js +53 -3
- package/src/errors.js +42 -14
- package/src/main.js +83 -18
- package/src/portal.js +156 -0
- package/src/projects.js +20 -2
- package/src/routes.js +14 -2
- package/src/schedules.js +14 -6
- package/src/settings.js +11 -5
- package/src/sync.js +389 -0
- package/src/ui.js +5 -0
- package/src/watch.js +35 -4
package/src/sync.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// `syndai sync <dir> [--app A] [--watch]`: keep a local folder (an Obsidian vault, any folder of
|
|
2
|
+
// notes) in a project's Knowledge (SPEC-knowledge-sources K6; docs: syndai.ai/docs/cli, "Sync a
|
|
3
|
+
// folder into Knowledge").
|
|
4
|
+
//
|
|
5
|
+
// One sync: walk the folder, hash every file, send the manifest. The server deletes the files no
|
|
6
|
+
// longer here and answers the paths that are new or changed; those are pushed in batches of at
|
|
7
|
+
// most 500 files and 8 MB. A text file goes as its UTF-8 text, any other file by name only. The
|
|
8
|
+
// folder's name in Knowledge is the directory's basename.
|
|
9
|
+
//
|
|
10
|
+
// Skipped: hidden files and folders (a name starting with `.`, so `.obsidian/`, `.trash/` and
|
|
11
|
+
// `.git/`), `node_modules/`, symlinks (never followed: one may point out of the folder, or
|
|
12
|
+
// loop), files over 10 MB, and the patterns in `<dir>/.syndaiignore`.
|
|
13
|
+
//
|
|
14
|
+
// `--watch` re-runs the whole sync once the folder has been quiet for a second, one sync at a
|
|
15
|
+
// time; Ctrl-C stops it. A file is hashed again only when its size or mtime moved (or it was
|
|
16
|
+
// hashed within 2 s of its mtime, which a coarse clock could hide), so a re-sync costs the
|
|
17
|
+
// changed files, not the vault. File events (`fs.watch`) are the trigger; a stat-only
|
|
18
|
+
// check every 10 s catches what they miss (a network drive, a dropped event), so an edit is in
|
|
19
|
+
// Knowledge within seconds either way.
|
|
20
|
+
//
|
|
21
|
+
// File I/O is synchronous: one file at a time through promises is ~20x slower on a big vault,
|
|
22
|
+
// and nothing else runs meanwhile.
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { CliError, EXIT, UnavailableError, explain } from "./errors.js";
|
|
28
|
+
import { interrupted } from "./http.js";
|
|
29
|
+
import { projectLabel, resolveProject } from "./projects.js";
|
|
30
|
+
|
|
31
|
+
/** Sent as text; the server's `TEXT_SUFFIXES` (backend/src/features/project_sources/ingest.py). */
|
|
32
|
+
export const TEXT_SUFFIXES = new Set([
|
|
33
|
+
".md",
|
|
34
|
+
".markdown",
|
|
35
|
+
".mdx",
|
|
36
|
+
".txt",
|
|
37
|
+
".text",
|
|
38
|
+
".csv",
|
|
39
|
+
".tsv",
|
|
40
|
+
".json",
|
|
41
|
+
".yaml",
|
|
42
|
+
".yml",
|
|
43
|
+
".org",
|
|
44
|
+
".rst",
|
|
45
|
+
".log",
|
|
46
|
+
]);
|
|
47
|
+
export const FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
48
|
+
export const MANIFEST_MAX = 20_000;
|
|
49
|
+
export const BATCH_FILES = 500;
|
|
50
|
+
export const BATCH_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
export const DEBOUNCE_MS = 1000;
|
|
52
|
+
const POLL_MS = 10_000;
|
|
53
|
+
const RETRY_MS = 30_000;
|
|
54
|
+
const RACY_MS = 2000; // the coarsest common mtime clock (FAT, exFAT)
|
|
55
|
+
const TOO_BIG = "over 10 MB";
|
|
56
|
+
const IGNORE_FILE = ".syndaiignore";
|
|
57
|
+
const DEFAULT_IGNORES = "node_modules/\n";
|
|
58
|
+
|
|
59
|
+
export async function sync(ctx, args) {
|
|
60
|
+
const [dir, ...extra] = args;
|
|
61
|
+
if (!dir || extra.length) throw new CliError(EXIT.USAGE, "Usage: syndai sync <dir> [--app A] [--watch]");
|
|
62
|
+
const root = path.resolve(ctx.io.cwd, dir);
|
|
63
|
+
if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
64
|
+
throw new CliError(EXIT.USAGE, `${dir} is not a folder.`);
|
|
65
|
+
}
|
|
66
|
+
const name = path.basename(root);
|
|
67
|
+
if (!name) throw new CliError(EXIT.USAGE, "Sync a folder with a name, not the filesystem root.");
|
|
68
|
+
const project = await resolveProject(ctx, { app: ctx.flags.app });
|
|
69
|
+
// `known`: path -> {size, mtimeMs, hashedAt, sha256 (null: skipped)} as of the last scan.
|
|
70
|
+
const target = { root, name, project, rules: [], known: new Map() };
|
|
71
|
+
show(ctx, target, await syncOnce(ctx, target));
|
|
72
|
+
return ctx.flags.watch ? watchFolder(ctx, target) : EXIT.OK;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- one sync ----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function syncOnce(ctx, target) {
|
|
78
|
+
const { client } = ctx;
|
|
79
|
+
const params = { coding_repository_id: target.project.id, name: target.name };
|
|
80
|
+
scan(target);
|
|
81
|
+
const files = [...target.known].filter(([, f]) => f.sha256).map(([rel, f]) => ({ path: rel, sha256: f.sha256 }));
|
|
82
|
+
if (files.length > MANIFEST_MAX) {
|
|
83
|
+
throw new CliError(
|
|
84
|
+
EXIT.USAGE,
|
|
85
|
+
`${target.name} has ${files.length} files; a synced folder holds up to ${MANIFEST_MAX}.`,
|
|
86
|
+
{ next: `list what to leave out in ${path.join(target.root, IGNORE_FILE)}` },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const manifest = await client.call("compareKnowledgeFolder", {
|
|
90
|
+
params,
|
|
91
|
+
body: { files },
|
|
92
|
+
retry: true, // the same manifest twice is the same answer
|
|
93
|
+
timeoutMs: 120_000,
|
|
94
|
+
});
|
|
95
|
+
const need = manifest.need.filter((rel) => target.known.get(rel)?.sha256);
|
|
96
|
+
const totals = { indexed: 0, linked: 0, failed: [] };
|
|
97
|
+
const progress = ctx.io.stderr.isTTY && need.length > BATCH_FILES;
|
|
98
|
+
let sent = 0;
|
|
99
|
+
let batch = [];
|
|
100
|
+
let size = 0;
|
|
101
|
+
const flush = async () => {
|
|
102
|
+
const pushed = await client.call("pushKnowledgeFolderFiles", {
|
|
103
|
+
params,
|
|
104
|
+
body: { files: batch },
|
|
105
|
+
retry: true, // a push upserts by path
|
|
106
|
+
timeoutMs: 120_000,
|
|
107
|
+
});
|
|
108
|
+
totals.indexed += pushed.indexed;
|
|
109
|
+
totals.linked += pushed.linked;
|
|
110
|
+
totals.failed.push(...pushed.failed);
|
|
111
|
+
sent += batch.length;
|
|
112
|
+
if (progress) ctx.ui.progress(ctx.ui.dim(`indexed ${sent} of ${need.length}`));
|
|
113
|
+
batch = [];
|
|
114
|
+
size = 0;
|
|
115
|
+
};
|
|
116
|
+
for (const rel of need) {
|
|
117
|
+
// Read again: a file edited since the scan goes with its new hash, so the next manifest
|
|
118
|
+
// matches it. One gone since is left to the next manifest, which removes it.
|
|
119
|
+
const file = readFile(target, rel);
|
|
120
|
+
if (!file?.sha256) continue;
|
|
121
|
+
const entry = {
|
|
122
|
+
path: rel,
|
|
123
|
+
sha256: file.sha256,
|
|
124
|
+
bytes: file.bytes.length,
|
|
125
|
+
modified_at: new Date(file.mtimeMs).toISOString(),
|
|
126
|
+
content: TEXT_SUFFIXES.has(path.extname(rel).toLowerCase()) ? file.bytes.toString("utf8") : null,
|
|
127
|
+
};
|
|
128
|
+
const bytes = Buffer.byteLength(JSON.stringify(entry)) + 1;
|
|
129
|
+
if (batch.length && (batch.length >= BATCH_FILES || size + bytes > BATCH_BYTES)) await flush();
|
|
130
|
+
batch.push(entry);
|
|
131
|
+
size += bytes;
|
|
132
|
+
}
|
|
133
|
+
if (batch.length) await flush();
|
|
134
|
+
return {
|
|
135
|
+
source_id: manifest.source_id,
|
|
136
|
+
files: files.length,
|
|
137
|
+
unchanged: files.length - need.length,
|
|
138
|
+
removed: manifest.removed,
|
|
139
|
+
...totals,
|
|
140
|
+
skipped: [...target.known].filter(([, f]) => !f.sha256).map(([rel]) => ({ path: rel, reason: TOO_BIG })),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Walk the folder into `target.known`, hashing only a file whose size or mtime moved. */
|
|
145
|
+
function scan(target) {
|
|
146
|
+
target.rules = loadRules(target.root);
|
|
147
|
+
const before = target.known;
|
|
148
|
+
target.known = new Map();
|
|
149
|
+
for (const rel of walk(target.root, target.rules)) {
|
|
150
|
+
const stat = statFile(target, rel);
|
|
151
|
+
const last = before.get(rel);
|
|
152
|
+
if (stat && same(last, stat) && trusted(last)) target.known.set(rel, last);
|
|
153
|
+
else if (stat) readFile(target, rel);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const same = (last, stat) => Boolean(last) && last.size === stat.size && last.mtimeMs === stat.mtimeMs;
|
|
158
|
+
|
|
159
|
+
/** A hash taken soon after the mtime may predate a same-size edit in that clock tick: redo it. */
|
|
160
|
+
const trusted = (last) => !last.sha256 || last.mtimeMs < last.hashedAt - RACY_MS;
|
|
161
|
+
|
|
162
|
+
/** Whether a stat-only walk differs from the last scan (a file added, removed or touched). */
|
|
163
|
+
function changedOnDisk(target) {
|
|
164
|
+
const rels = walk(target.root, loadRules(target.root));
|
|
165
|
+
if (rels.length !== target.known.size) return true;
|
|
166
|
+
return rels.some((rel) => {
|
|
167
|
+
const stat = statFile(target, rel);
|
|
168
|
+
const last = target.known.get(rel);
|
|
169
|
+
if (!stat || !same(last, stat)) return true;
|
|
170
|
+
return !trusted(last) && readFile(target, rel)?.sha256 !== last.sha256;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function statFile(target, rel) {
|
|
175
|
+
try {
|
|
176
|
+
const stat = fs.lstatSync(path.join(target.root, ...rel.split("/")));
|
|
177
|
+
return stat.isFile() ? stat : null;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return null;
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read and hash one file into `target.known` and return it with its `bytes`; one over 10 MB is
|
|
186
|
+
* recorded with `sha256: null` (skipped) and never read. Null when it is gone or unreadable.
|
|
187
|
+
*/
|
|
188
|
+
function readFile(target, rel) {
|
|
189
|
+
const full = path.join(target.root, ...rel.split("/"));
|
|
190
|
+
let stat;
|
|
191
|
+
let bytes;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.lstatSync(full);
|
|
194
|
+
if (!stat.isFile()) return null;
|
|
195
|
+
if (stat.size <= FILE_MAX_BYTES) bytes = fs.readFileSync(full);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (["ENOENT", "EACCES", "EPERM", "EISDIR", "ENOTDIR"].includes(err.code)) {
|
|
198
|
+
target.known.delete(rel);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
const small = bytes && bytes.length <= FILE_MAX_BYTES;
|
|
204
|
+
const file = {
|
|
205
|
+
size: small ? bytes.length : stat.size,
|
|
206
|
+
mtimeMs: stat.mtimeMs,
|
|
207
|
+
hashedAt: Date.now(),
|
|
208
|
+
sha256: small ? createHash("sha256").update(bytes).digest("hex") : null,
|
|
209
|
+
};
|
|
210
|
+
target.known.set(rel, file);
|
|
211
|
+
return { ...file, bytes };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function show(ctx, target, s) {
|
|
215
|
+
const lines = [
|
|
216
|
+
`Synced ${target.name} to ${projectLabel(target.project)}: ` +
|
|
217
|
+
`${s.indexed} indexed, ${s.linked} linked, ${s.failed.length} failed`,
|
|
218
|
+
];
|
|
219
|
+
if (s.removed) lines.push(` removed ${s.removed} deleted here`);
|
|
220
|
+
for (const f of s.failed) lines.push(` failed ${f.path}: ${f.error}`);
|
|
221
|
+
for (const f of s.skipped) lines.push(` skipped ${f.path}: ${f.reason}`);
|
|
222
|
+
ctx.ui.result({ folder: target.name, dir: target.root, project_id: target.project.id, ...s }, lines.join("\n"));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- what is skipped ---------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function loadRules(root) {
|
|
228
|
+
let own = "";
|
|
229
|
+
try {
|
|
230
|
+
own = fs.readFileSync(path.join(root, IGNORE_FILE), "utf8");
|
|
231
|
+
} catch {
|
|
232
|
+
// No .syndaiignore: the defaults alone.
|
|
233
|
+
}
|
|
234
|
+
return parseIgnore(DEFAULT_IGNORES + own);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `.syndaiignore`, gitignore-like and kept simple: one pattern a line, `#` comments, `dir/` for
|
|
239
|
+
* folders only, a pattern with a `/` (or a leading one) anchored at the folder's root and any
|
|
240
|
+
* other matched against a name at any depth, `*` and `?` within a name, `**` across folders.
|
|
241
|
+
* No `!` negation.
|
|
242
|
+
*/
|
|
243
|
+
export function parseIgnore(text) {
|
|
244
|
+
const rules = [];
|
|
245
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
246
|
+
let line = raw.trim();
|
|
247
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
248
|
+
const dirOnly = line.endsWith("/");
|
|
249
|
+
line = line.replace(/\/+$/, "");
|
|
250
|
+
const anchored = line.includes("/");
|
|
251
|
+
line = line.replace(/^\/+/, "");
|
|
252
|
+
if (line) rules.push({ dirOnly, anchored, regex: globRegex(line) });
|
|
253
|
+
}
|
|
254
|
+
return rules;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function globRegex(glob) {
|
|
258
|
+
let source = "";
|
|
259
|
+
for (let i = 0; i < glob.length; i++) {
|
|
260
|
+
const ch = glob[i];
|
|
261
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
262
|
+
const slash = glob[i + 2] === "/";
|
|
263
|
+
source += slash ? "(?:.*/)?" : ".*";
|
|
264
|
+
i += slash ? 2 : 1;
|
|
265
|
+
} else if (ch === "*") source += "[^/]*";
|
|
266
|
+
else if (ch === "?") source += "[^/]";
|
|
267
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
268
|
+
}
|
|
269
|
+
return new RegExp(`^${source}$`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether the entry at `rel` (`/`-separated; `isDir` for a folder) is skipped. */
|
|
273
|
+
export function ignored(rules, rel, isDir) {
|
|
274
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
275
|
+
if (name.startsWith(".")) return true;
|
|
276
|
+
return rules.some((r) => (!r.dirOnly || isDir) && r.regex.test(r.anchored ? rel : name));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The files to sync, sorted; a skipped folder is never entered. */
|
|
280
|
+
function walk(root, rules) {
|
|
281
|
+
const files = [];
|
|
282
|
+
const visit = (dir, prefix) => {
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (prefix && ["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return;
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
293
|
+
// A Dirent describes the link itself: a symlink is neither, so it is never followed.
|
|
294
|
+
if (entry.isDirectory()) {
|
|
295
|
+
if (!ignored(rules, rel, true)) visit(path.join(dir, entry.name), rel);
|
|
296
|
+
} else if (entry.isFile() && !ignored(rules, rel, false)) {
|
|
297
|
+
files.push(rel);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
visit(root, "");
|
|
302
|
+
return files;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A change event for `rel` matters unless it is in a skipped folder or is a skipped file. */
|
|
306
|
+
function relevant(rules, rel) {
|
|
307
|
+
if (rel === IGNORE_FILE) return true;
|
|
308
|
+
const parts = rel.split("/");
|
|
309
|
+
for (let i = 1; i < parts.length; i++) {
|
|
310
|
+
if (ignored(rules, parts.slice(0, i).join("/"), true)) return false;
|
|
311
|
+
}
|
|
312
|
+
return !ignored(rules, rel, false);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- --watch -----------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
async function watchFolder(ctx, target) {
|
|
318
|
+
const { io, ui } = ctx;
|
|
319
|
+
const state = { changed: false, wake: null };
|
|
320
|
+
let watcher = null;
|
|
321
|
+
const eventsLost = (err) => {
|
|
322
|
+
watcher?.close();
|
|
323
|
+
watcher = null;
|
|
324
|
+
ui.progress(`${ui.yellow("warning:")} file events stopped (${err.code ?? err.message}); checking every 10 s instead.`);
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
watcher = io.watchDir(
|
|
328
|
+
target.root,
|
|
329
|
+
(file) => {
|
|
330
|
+
if (file && !relevant(target.rules, String(file).split(path.sep).join("/"))) return;
|
|
331
|
+
state.changed = true;
|
|
332
|
+
state.wake?.();
|
|
333
|
+
},
|
|
334
|
+
eventsLost,
|
|
335
|
+
);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
eventsLost(err);
|
|
338
|
+
}
|
|
339
|
+
ui.progress(ui.dim(`Watching ${target.root} for changes (Ctrl-C stops).`));
|
|
340
|
+
try {
|
|
341
|
+
for (;;) {
|
|
342
|
+
if (!state.changed && (await nextEvent(io, state)) === "poll" && !changedOnDisk(target)) continue;
|
|
343
|
+
// Wait until a second passes with no change, so a burst of saves is one sync.
|
|
344
|
+
do {
|
|
345
|
+
state.changed = false;
|
|
346
|
+
await pause(io, DEBOUNCE_MS);
|
|
347
|
+
} while (state.changed);
|
|
348
|
+
try {
|
|
349
|
+
const s = await syncOnce(ctx, target);
|
|
350
|
+
if (s.indexed || s.linked || s.failed.length || s.removed) show(ctx, target, s);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (!(err instanceof UnavailableError)) throw err;
|
|
353
|
+
ui.progress(`${ui.yellow("warning:")} ${explain(err).message} Trying again in 30 s.`);
|
|
354
|
+
await pause(io, RETRY_MS);
|
|
355
|
+
state.changed = true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
watcher?.close();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** "event" on a relevant file event, "poll" after 10 s without one; Ctrl-C interrupts. */
|
|
364
|
+
function nextEvent(io, state) {
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
if (io.signal.aborted) return reject(interrupted());
|
|
367
|
+
const done = (outcome) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
io.signal.removeEventListener("abort", onAbort);
|
|
370
|
+
state.wake = null;
|
|
371
|
+
if (outcome instanceof Error) reject(outcome);
|
|
372
|
+
else resolve(outcome);
|
|
373
|
+
};
|
|
374
|
+
const onAbort = () => done(interrupted());
|
|
375
|
+
const timer = setTimeout(() => done("poll"), POLL_MS);
|
|
376
|
+
io.signal.addEventListener("abort", onAbort, { once: true });
|
|
377
|
+
state.wake = () => done("event");
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function pause(io, ms) {
|
|
382
|
+
if (io.signal.aborted) throw interrupted();
|
|
383
|
+
try {
|
|
384
|
+
await io.sleep(ms, io.signal);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (io.signal.aborted) throw interrupted();
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -133,6 +133,11 @@ export function openBrowser(url) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** `review`'s rule for a hand-off page: open it on a terminal; the caller always prints it. */
|
|
137
|
+
export function openPage({ ui, io }, url) {
|
|
138
|
+
if (ui.interactive && !ui.json) io.openUrl(url);
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
/** Show long text through $PAGER on a terminal; print it plainly otherwise. */
|
|
137
142
|
export function page(proc, text) {
|
|
138
143
|
if (!proc.stdout.isTTY) {
|
package/src/watch.js
CHANGED
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
// cursor. Ctrl-C only detaches: it never sends a cancel. `--logs` also tails the run's SSE
|
|
6
6
|
// events (resuming with Last-Event-ID) next to the loop.
|
|
7
7
|
|
|
8
|
-
import { ApiError, CliError, EXIT, explain } from "./errors.js";
|
|
8
|
+
import { ApiError, CliError, EXIT, UnavailableError, explain } from "./errors.js";
|
|
9
9
|
import { backoffMs } from "./http.js";
|
|
10
|
-
import { ask, ulid } from "./ui.js";
|
|
10
|
+
import { ask, openPage, ulid } from "./ui.js";
|
|
11
11
|
|
|
12
12
|
const POLL_WAIT_S = 45;
|
|
13
13
|
const MIN_POLL_GAP_MS = 1000;
|
|
14
14
|
const DEADLINE_GRACE_MS = 2000;
|
|
15
15
|
const TIMED_OUT = Symbol("timed out");
|
|
16
|
+
// A deploy answers 502/503 for a minute or two, longer than one request's retries: the
|
|
17
|
+
// watch keeps polling through it and gives up only after this long without an answer.
|
|
18
|
+
const OUTAGE_MAX_MS = 10 * 60_000;
|
|
19
|
+
const OUTAGE_MAX_WAIT_MS = 30_000;
|
|
16
20
|
|
|
17
21
|
// Exit code per ChangeState state (docs: syndai.ai/docs/cli, "Exit codes").
|
|
18
22
|
const STATE_EXIT = {
|
|
@@ -21,6 +25,7 @@ const STATE_EXIT = {
|
|
|
21
25
|
ready: EXIT.OK,
|
|
22
26
|
live: EXIT.OK,
|
|
23
27
|
live_unverified: EXIT.UNVERIFIED,
|
|
28
|
+
unverified: EXIT.UNVERIFIED,
|
|
24
29
|
failed: EXIT.FAILED,
|
|
25
30
|
reverted: EXIT.FAILED,
|
|
26
31
|
cancelled: EXIT.FAILED,
|
|
@@ -35,6 +40,7 @@ export function nextFor(state) {
|
|
|
35
40
|
const id = state.run_id;
|
|
36
41
|
if (state.state === "needs_input") return `syndai answer ${id}`;
|
|
37
42
|
if (state.state === "held") return `syndai review ${id}`;
|
|
43
|
+
if (state.state === "unverified") return state.pr_url ?? state.run_url ?? null;
|
|
38
44
|
if (state.state === "ready") return `syndai go-live ${id}`;
|
|
39
45
|
if (state.state === "live_unverified") return `syndai watch ${id}`;
|
|
40
46
|
return state.done ? null : `syndai watch ${id}`;
|
|
@@ -55,6 +61,8 @@ export function describe(state) {
|
|
|
55
61
|
return `Held: ${state.held?.message ?? state.held?.reason ?? "needs your review"}`;
|
|
56
62
|
case "ready":
|
|
57
63
|
return `Ready for review${headline}`;
|
|
64
|
+
case "unverified":
|
|
65
|
+
return `Delivered, but checks could not be verified${headline}`;
|
|
58
66
|
case "live":
|
|
59
67
|
return `Live: ${state.live_url ?? state.project?.live_url ?? ""}`.trim();
|
|
60
68
|
case "live_unverified":
|
|
@@ -68,12 +76,19 @@ export function describe(state) {
|
|
|
68
76
|
export function finish(ctx, state, { exitCode = exitFor(state), next = nextFor(state) } = {}) {
|
|
69
77
|
const { ui } = ctx;
|
|
70
78
|
const lines = [describe(state)];
|
|
79
|
+
for (const link of questionLinks(state)) lines.push(`Connect it here: ${link}`);
|
|
80
|
+
if (state.pr_url) lines.push(ui.dim(state.pr_url));
|
|
71
81
|
if (state.run_url) lines.push(ui.dim(state.run_url));
|
|
72
82
|
ui.result({ ...state, ...(next ? { next } : {}) }, lines.join("\n"));
|
|
73
83
|
if (next && !ui.json) ui.progress(`Next: ${next}`);
|
|
74
84
|
return exitCode;
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/** The pages an open question needs (a connection to make), each an absolute portal URL. */
|
|
88
|
+
function questionLinks(state) {
|
|
89
|
+
return state.state === "needs_input" ? (state.needs_input ?? []).map((q) => q.link).filter(Boolean) : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export async function getChange(ctx, runId) {
|
|
78
93
|
return ctx.client.call("changeState", { params: { run_id: runId }, query: { wait: 0 } });
|
|
79
94
|
}
|
|
@@ -95,6 +110,8 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
|
|
|
95
110
|
let last = null;
|
|
96
111
|
let lastLine = null;
|
|
97
112
|
const answered = new Set(); // a state read before our answer landed must not ask again
|
|
113
|
+
let outageSince = null;
|
|
114
|
+
let outageTries = 0;
|
|
98
115
|
try {
|
|
99
116
|
for (;;) {
|
|
100
117
|
const remaining = deadline === null ? Number.POSITIVE_INFINITY : deadline - io.now();
|
|
@@ -111,14 +128,24 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
|
|
|
111
128
|
});
|
|
112
129
|
} catch (err) {
|
|
113
130
|
if (err === TIMED_OUT) return timedOut(ctx, runId, last);
|
|
114
|
-
throw err;
|
|
131
|
+
if (!(err instanceof UnavailableError)) throw err;
|
|
132
|
+
outageSince ??= io.now();
|
|
133
|
+
if (io.now() - outageSince >= OUTAGE_MAX_MS) throw err;
|
|
134
|
+
if (outageTries++ === 0) ui.progress(ui.dim(`· Syndai is unreachable (${err.message}); still watching`));
|
|
135
|
+
await io.sleep(Math.min(OUTAGE_MAX_WAIT_MS, backoffMs(outageTries + 4)), io.signal);
|
|
136
|
+
continue;
|
|
115
137
|
}
|
|
138
|
+
outageSince = null;
|
|
139
|
+
outageTries = 0;
|
|
116
140
|
const line = describe(state);
|
|
117
141
|
const changed = line !== lastLine || state.cursor !== cursor;
|
|
118
142
|
const final =
|
|
119
143
|
(state.done && state.state !== untilNot) || (state.state === "needs_input" && !ui.interactive);
|
|
120
144
|
if (final) return finish(ctx, state);
|
|
121
|
-
if (line !== lastLine)
|
|
145
|
+
if (line !== lastLine) {
|
|
146
|
+
ui.progress(`${ui.dim("·")} ${line}`);
|
|
147
|
+
if (state.pr_url) ui.progress(ui.dim(state.pr_url));
|
|
148
|
+
}
|
|
122
149
|
lastLine = line;
|
|
123
150
|
cursor = state.cursor ?? cursor;
|
|
124
151
|
last = state;
|
|
@@ -162,6 +189,10 @@ function timedOut(ctx, runId, last) {
|
|
|
162
189
|
export async function answerInteractively(ctx, runId, questions) {
|
|
163
190
|
for (const question of questions) {
|
|
164
191
|
ctx.ui.progress(ctx.ui.bold(question.summary ?? "A question about your change"));
|
|
192
|
+
if (question.link) {
|
|
193
|
+
ctx.ui.progress(`Connect it here: ${question.link}`);
|
|
194
|
+
openPage(ctx, question.link);
|
|
195
|
+
}
|
|
165
196
|
const entries = [];
|
|
166
197
|
for (const input of question.inputs ?? []) {
|
|
167
198
|
const entry = await askInput(ctx, input);
|