baesian 0.0.0-stage → 1.0.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.md +105 -0
- package/README.md +217 -2
- package/dist/agents.js +249 -0
- package/dist/api.js +13 -0
- package/dist/bin.js +14 -0
- package/dist/ci.js +82 -0
- package/dist/cite.js +66 -0
- package/dist/cli.js +831 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
- package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
- package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
- package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
- package/dist/client/assets/index-9yf70Vqv.css +1 -0
- package/dist/client/assets/index-Dwg9e0YR.js +15 -0
- package/dist/client/index.html +15 -0
- package/dist/facts.js +278 -0
- package/dist/folder-storage.js +71 -0
- package/dist/frontmatter.js +38 -0
- package/dist/gate-paths.js +77 -0
- package/dist/gate.js +237 -0
- package/dist/git.js +100 -0
- package/dist/globs.js +120 -0
- package/dist/hookblock.js +146 -0
- package/dist/hooks.js +911 -0
- package/dist/ids.js +23 -0
- package/dist/login.js +301 -0
- package/dist/mcp.js +303 -0
- package/dist/merges.js +849 -0
- package/dist/modes.js +245 -0
- package/dist/notify.js +91 -0
- package/dist/ops.js +381 -0
- package/dist/passkey.js +86 -0
- package/dist/personal.js +163 -0
- package/dist/practice-answers.js +42 -0
- package/dist/proposals.js +197 -0
- package/dist/quote.js +28 -0
- package/dist/report.js +261 -0
- package/dist/routing.js +411 -0
- package/dist/schema.js +1178 -0
- package/dist/server.js +514 -0
- package/dist/similarity.js +45 -0
- package/dist/sshsig.js +159 -0
- package/dist/state.js +135 -0
- package/dist/storage.js +61 -0
- package/dist/store.js +2259 -0
- package/dist/style.js +408 -0
- package/dist/sync.js +797 -0
- package/dist/trailers.js +121 -0
- package/dist/trust.js +222 -0
- package/dist/usage-fields.js +161 -0
- package/dist/vault.js +849 -0
- package/dist/verify.js +443 -0
- package/dist/version.js +70 -0
- package/dist/wait.js +449 -0
- package/dist/wikilinks.js +25 -0
- package/package.json +71 -4
package/dist/sync.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cite } from "./cite.js";
|
|
6
|
+
import { isShallow, rootCommits } from "./git.js";
|
|
7
|
+
import { Offline, REQUEST_MS, SignedOut, readGrant, reason, refreshGrant } from "./login.js";
|
|
8
|
+
import { parseDoc } from "./frontmatter.js";
|
|
9
|
+
import { CARD_ID, CardDraft, DefaultBranch, Presence, RelayState, asWritten, isLedgerBody, newestFormat, readRelayEntry, upgradeFile } from "./schema.js";
|
|
10
|
+
import { alreadyExists, checkPath } from "./storage.js";
|
|
11
|
+
import { DEFAULT_BRANCH } from "./store.js";
|
|
12
|
+
import { readTrustFile } from "./trust.js";
|
|
13
|
+
import { RELEASE_HEADER, TOO_OLD, VERSION } from "./version.js";
|
|
14
|
+
// The CLI's side of the relay (M9d, Hosted Service sections 1 and 2): pulling the project's log into the live layer, as
|
|
15
|
+
// if each file had been written here; pushing the files an agent writes, in order; `relay.json`, what this machine
|
|
16
|
+
// keeps of the relay (P16 section 8); and, while `baesian wait` runs, the relay's live socket (B-bv49e) and the waiting
|
|
17
|
+
// marker's heartbeat. There's no daemon: the commands sync when they already touch the live layer (`syncRelay()`,
|
|
18
|
+
// `aroundFiling()`, `waitWithRelay()`). Offline, or signed out, everything local works as before, and what's new goes up
|
|
19
|
+
// at the next command that reaches the relay. What counts is the store's to say: in a hosted repo, only what the service
|
|
20
|
+
// signed (D138), and what was in the record when the repo was linked (B-45xz8). Those answers from before linking go up
|
|
21
|
+
// once, at first link, before anyone answers on the website (B-3zjjx).
|
|
22
|
+
/** `relay.json` in the live layer. */
|
|
23
|
+
export const RELAY_STATE = "relay.json";
|
|
24
|
+
/**
|
|
25
|
+
* The live layer's top folders a relay's log may write into: the files it holds, with the ledger and merges another
|
|
26
|
+
* machine found (D181). Never presence, cursors or markers.
|
|
27
|
+
*/
|
|
28
|
+
const PULLED = new Set(["cards", "decks", "verdicts", "rules", "topics", "preferences", "trust", "attachments", "ledger", "merges"]);
|
|
29
|
+
const ID = CARD_ID.source.replace(/^\^|\$$/g, "");
|
|
30
|
+
const REVISION = new RegExp(`^cards/(${ID})/r(\\d+)\\.md$`);
|
|
31
|
+
const CARD_FILE = new RegExp(`^cards/(${ID})/(?:gate-r\\d+-cli\\.json|withdrawn\\.json|fallback-[0-9a-z]+\\.json)$`);
|
|
32
|
+
const ATTACHMENT = new RegExp(`^attachments/(${ID})-[0-9a-z]+\\.json$`);
|
|
33
|
+
const DECK = /^decks\/[a-z0-9][a-z0-9-]*\.md$/;
|
|
34
|
+
const LEDGER = new RegExp(`^ledger/(${ID})-[0-9a-z]+\\.md$`);
|
|
35
|
+
const MERGE = /^merges\/(?:[0-9a-f]{40}|[0-9a-f]{64})-[0-9a-f]{12}\.json$/;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a live-layer file is one of the kinds an agent writes, which a machine pushes (Hosted Service section 1): card
|
|
38
|
+
* revisions, the CLI's gate records, withdrawals, fallbacks, attachments and decks; and what the hooks write (D181), the
|
|
39
|
+
* ledger's entries and merges. Waiting markers go up only while `baesian wait` runs. Everything a person writes comes
|
|
40
|
+
* down, signed, and never goes up.
|
|
41
|
+
*/
|
|
42
|
+
export function agentWritten(rel) {
|
|
43
|
+
return DECK.test(rel) || REVISION.test(rel) || CARD_FILE.test(rel) || ATTACHMENT.test(rel) || LEDGER.test(rel) || MERGE.test(rel);
|
|
44
|
+
}
|
|
45
|
+
/** The card a file belongs to, if any: a ledger entry is its card's. */
|
|
46
|
+
const cardOf = (rel) => (REVISION.exec(rel) ?? CARD_FILE.exec(rel) ?? ATTACHMENT.exec(rel) ?? LEDGER.exec(rel))?.[1];
|
|
47
|
+
/**
|
|
48
|
+
* Whether a ledger entry may leave the machine (D181): its body is the one line naming the commit's short ID and the card
|
|
49
|
+
* (`ledgerBody()`), and it names its commit. Entries written before D120 listed the commit's files, and stay here.
|
|
50
|
+
*/
|
|
51
|
+
export function plainLedger(bytes) {
|
|
52
|
+
try {
|
|
53
|
+
const { meta, body } = parseDoc(Buffer.from(bytes).toString("utf8"));
|
|
54
|
+
const m = meta;
|
|
55
|
+
return typeof m.commit === "string" && typeof m.card === "string" && isLedgerBody(body, m.commit, m.card);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const encode = (text) => new TextEncoder().encode(text);
|
|
62
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
63
|
+
const same = (a, b) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(Buffer.from(b.buffer, b.byteOffset, b.byteLength));
|
|
64
|
+
const encodePath = (rel) => rel.split("/").map(encodeURIComponent).join("/");
|
|
65
|
+
/** The relay answered, but not as hoped: a refusal that isn't about the grant. */
|
|
66
|
+
export class RelayError extends Error {
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The relay refused this Baesian as too old to read what it holds (B-4p43b), naming the release to install. Nothing was
|
|
70
|
+
* pulled or pushed; everything local works as before.
|
|
71
|
+
*/
|
|
72
|
+
export class TooOld extends RelayError {
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------- the relay's addresses (B-7z97x)
|
|
75
|
+
/**
|
|
76
|
+
* A machine's client for one project's relay: every address under `/v1/projects/{project}/`, with the grant's access
|
|
77
|
+
* token as `Authorization: Bearer`. The token is refreshed when it's within a minute of expiring, and on a 401: unless
|
|
78
|
+
* another command on this machine refreshed meanwhile, whose tokens it then takes from the grant file.
|
|
79
|
+
*/
|
|
80
|
+
export class RelayClient {
|
|
81
|
+
grant;
|
|
82
|
+
http;
|
|
83
|
+
constructor(grant, http = {}) {
|
|
84
|
+
this.grant = grant;
|
|
85
|
+
this.http = http;
|
|
86
|
+
}
|
|
87
|
+
get service() {
|
|
88
|
+
return this.grant.service;
|
|
89
|
+
}
|
|
90
|
+
get project() {
|
|
91
|
+
return this.grant.project;
|
|
92
|
+
}
|
|
93
|
+
/** The access token, refreshed first when it has less than a minute left. */
|
|
94
|
+
async token() {
|
|
95
|
+
if (Date.parse(this.grant.access_expires) - Date.now() < 60_000)
|
|
96
|
+
this.grant = await refreshGrant(this.grant, this.http);
|
|
97
|
+
return this.grant.access_token;
|
|
98
|
+
}
|
|
99
|
+
/** One request to the project's relay: its status and its JSON, if any. */
|
|
100
|
+
async request(method, rest, body) {
|
|
101
|
+
for (let attempt = 0;; attempt++) {
|
|
102
|
+
const token = await this.token();
|
|
103
|
+
const signal = this.http.signal ? AbortSignal.any([this.http.signal, AbortSignal.timeout(REQUEST_MS)]) : AbortSignal.timeout(REQUEST_MS);
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = await (this.http.fetch ?? fetch)(`${this.service}/v1/projects/${encodeURIComponent(this.project)}/${rest}`, {
|
|
107
|
+
method,
|
|
108
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json", [RELEASE_HEADER]: VERSION, ...(body ? { "content-type": "application/octet-stream" } : {}) },
|
|
109
|
+
...(body ? { body } : {}),
|
|
110
|
+
signal,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
throw new Offline(`couldn't reach ${this.service} (${reason(e)})`);
|
|
115
|
+
}
|
|
116
|
+
if (r.status === 401 && attempt === 0) {
|
|
117
|
+
await r.body?.cancel();
|
|
118
|
+
const current = readGrant(this.project);
|
|
119
|
+
if (!current || current.service !== this.service)
|
|
120
|
+
throw new SignedOut(`this machine isn't signed in to project ${this.project} any more`);
|
|
121
|
+
this.grant = current.access_token !== token ? current : await refreshGrant(current, this.http);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (r.status === 401)
|
|
125
|
+
throw new SignedOut(`${this.service} refused this machine's grant for project ${this.project}`);
|
|
126
|
+
const text = await r.text().catch(() => "");
|
|
127
|
+
let json;
|
|
128
|
+
try {
|
|
129
|
+
json = text ? JSON.parse(text) : undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
json = undefined;
|
|
133
|
+
}
|
|
134
|
+
// The relay serves no Baesian too old to read what it holds (B-4p43b): it says so before anything is pulled.
|
|
135
|
+
if (r.status === TOO_OLD && typeof json?.minimum === "string")
|
|
136
|
+
throw new TooOld(errorOf({ status: r.status, json }));
|
|
137
|
+
return { status: r.status, json };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** The live updates' address (B-bv49e), as a WebSocket URL. */
|
|
141
|
+
liveUrl() {
|
|
142
|
+
return `${this.service.replace(/^http/, "ws")}/v1/projects/${encodeURIComponent(this.project)}/live`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The service's spare keys (B-x47ek), from `GET /v1/keys`: service key files whose private halves are kept offline and
|
|
147
|
+
* which the service never signs with, for CI to pin beside the key in use. Or why they couldn't be read.
|
|
148
|
+
*/
|
|
149
|
+
export async function spareKeys(service, http = {}) {
|
|
150
|
+
const signal = http.signal ? AbortSignal.any([http.signal, AbortSignal.timeout(REQUEST_MS)]) : AbortSignal.timeout(REQUEST_MS);
|
|
151
|
+
let body;
|
|
152
|
+
try {
|
|
153
|
+
const r = await (http.fetch ?? fetch)(`${service}/v1/keys`, { headers: { accept: "application/json", [RELEASE_HEADER]: VERSION }, signal });
|
|
154
|
+
if (r.status !== 200) {
|
|
155
|
+
await r.body?.cancel();
|
|
156
|
+
return { keys: [], problem: `${service} didn't give its keys (HTTP ${r.status})` };
|
|
157
|
+
}
|
|
158
|
+
body = (await r.json());
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
return { keys: [], problem: `couldn't read ${service}'s keys (${reason(e)})` };
|
|
162
|
+
}
|
|
163
|
+
const keys = [];
|
|
164
|
+
const problems = typeof body.problem === "string" ? [`${service} names a spare key it can't read: ${body.problem}`] : [];
|
|
165
|
+
for (const [i, raw] of (Array.isArray(body.spare) ? body.spare : []).entries()) {
|
|
166
|
+
try {
|
|
167
|
+
const k = readTrustFile(raw, `${service}'s spare key ${i + 1}`);
|
|
168
|
+
if (k.type === "service-key")
|
|
169
|
+
keys.push(k);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
problems.push(e instanceof Error ? e.message.split("\n")[0] : String(e));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return problems.length ? { keys, problem: problems.join("; ") } : { keys };
|
|
176
|
+
}
|
|
177
|
+
/** A relay's refusal, in its own words. */
|
|
178
|
+
const errorOf = (r) => {
|
|
179
|
+
const e = r.json?.error;
|
|
180
|
+
return typeof e === "string" ? e.split("\n")[0] : `HTTP ${r.status}`;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Why this checkout mustn't sync with the project its config names (B-5gnea), or nothing: its history must start at the
|
|
184
|
+
* project's first commit, so a copy, such as one made from a template, doesn't mix its cards with the original's. A
|
|
185
|
+
* shallow clone can't see its first commit, and skips the check.
|
|
186
|
+
*/
|
|
187
|
+
export function linkProblem(store, c = store.config()) {
|
|
188
|
+
if (!c.project || !store.hasRepo)
|
|
189
|
+
return undefined;
|
|
190
|
+
if (!c.first_commit)
|
|
191
|
+
return `\`${store.personal ? ".git/baesian/config.json" : ".baesian/config.json"}\` names project ${c.project} but not the repo's first commit, so this checkout can't be told from a copy${cite("B-5gnea")}: run \`baesian login\` to link it again`;
|
|
192
|
+
const top = store.repo.toplevel;
|
|
193
|
+
if (isShallow(top))
|
|
194
|
+
return undefined;
|
|
195
|
+
const roots = rootCommits(top);
|
|
196
|
+
if (!roots)
|
|
197
|
+
return `this checkout has no commits yet, so it can't be checked against project ${c.project}'s first commit${cite("B-5gnea")}: it doesn't sync until it has one`;
|
|
198
|
+
if (roots.includes(c.first_commit))
|
|
199
|
+
return undefined;
|
|
200
|
+
return `this checkout's history doesn't start at project ${c.project}'s first commit (${c.first_commit.slice(0, 12)}), so it's a copy of that repo, such as one made from a template, not the repo itself: it doesn't sync with that project${cite("B-5gnea")}. Run \`baesian login\` to link it to a project of its own.`;
|
|
201
|
+
}
|
|
202
|
+
/** Whether this repo syncs, and if it does, the client to sync with: the config's service and project, and this machine's grant. */
|
|
203
|
+
export function relayFor(store, http = {}) {
|
|
204
|
+
const c = store.config();
|
|
205
|
+
if (!c.service || !c.project)
|
|
206
|
+
return { kind: "off" };
|
|
207
|
+
const problem = linkProblem(store, c);
|
|
208
|
+
if (problem)
|
|
209
|
+
return { kind: "stopped", why: `relay: ${problem}` };
|
|
210
|
+
let grant;
|
|
211
|
+
try {
|
|
212
|
+
grant = readGrant(c.project);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
return { kind: "signed-out", why: `relay: ${e instanceof Error ? e.message : String(e)}` };
|
|
216
|
+
}
|
|
217
|
+
if (!grant || grant.service !== c.service) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "signed-out",
|
|
220
|
+
why: `relay: this machine isn't signed in to project ${c.project} on ${c.service}, so nothing goes to the reviewer or comes back: ask the person to run \`baesian login\` here. Everything else works as before.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { kind: "on", client: new RelayClient(grant, http) };
|
|
224
|
+
}
|
|
225
|
+
/** This machine's state for a relay, from `relay.json`; afresh when there's none, or it's for another relay, or unreadable. */
|
|
226
|
+
export function readRelayState(store, service, project) {
|
|
227
|
+
const fresh = { type: "relay", format: newestFormat("relay"), service, project, pulled: 0, uploaded: [], at: new Date(0).toISOString() };
|
|
228
|
+
const bytes = store.storage.read(RELAY_STATE);
|
|
229
|
+
if (!bytes)
|
|
230
|
+
return fresh;
|
|
231
|
+
try {
|
|
232
|
+
const s = RelayState.parse(upgradeFile("relay", JSON.parse(Buffer.from(bytes).toString("utf8")), RELAY_STATE));
|
|
233
|
+
return s.service === service && s.project === project ? s : fresh;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// unreadable, or a newer Baesian's: pulling again from the start is harmless, since every file is written once
|
|
237
|
+
return fresh;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Replace `relay.json` whole (`Storage.replace`): a reader sees the old state or the new. */
|
|
241
|
+
function saveState(store, w) {
|
|
242
|
+
const state = RelayState.parse({ type: "relay", format: newestFormat("relay"), service: w.service, project: w.project, pulled: w.pulled, uploaded: [...w.uploaded].sort(), ...(w.defaultBranch ? { default_branch: w.defaultBranch } : {}), at: new Date().toISOString() });
|
|
243
|
+
store.storage.replace(RELAY_STATE, encode(`${JSON.stringify(asWritten("relay", state), null, 2)}\n`));
|
|
244
|
+
}
|
|
245
|
+
const report = (state, notes = []) => ({ state, notes, pulled: [], pushed: [], replaced: [], renamed: [] });
|
|
246
|
+
/** Whether the worktree's record holds a file of the live layer's. */
|
|
247
|
+
function inRecord(store, rel) {
|
|
248
|
+
return store.hasRepo && existsSync(join(store.record, ...rel.split("/")));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Take a card whose ID another machine's card took on the relay out of this machine's layer and record, keeping what its
|
|
252
|
+
* agent filed, so the relay's card can come down under that ID, and this one be filed again under a new one.
|
|
253
|
+
*/
|
|
254
|
+
function takeOut(store, id) {
|
|
255
|
+
const latest = store.card(id);
|
|
256
|
+
if (!latest)
|
|
257
|
+
return undefined;
|
|
258
|
+
const asked = store.gateRecordBy(id, latest.meta.revision, "cli")?.asked;
|
|
259
|
+
const fields = new Set(Object.keys(CardDraft.shape));
|
|
260
|
+
const draft = Object.fromEntries(Object.entries(latest.meta).filter(([k]) => fields.has(k) && k !== "level" && k !== "type" && k !== "format"));
|
|
261
|
+
const out = { id, draft: { ...draft, ...(asked ? { level: asked } : {}) }, body: latest.body, agent: latest.meta.source_agent, model: latest.meta.source_model, withdrawn: store.withdrawal(id) !== undefined };
|
|
262
|
+
for (const f of store.storage.list(`cards/${id}`)) {
|
|
263
|
+
const rel = `cards/${id}/${f}`;
|
|
264
|
+
const bytes = store.storage.read(rel);
|
|
265
|
+
store.storage.remove(rel);
|
|
266
|
+
if (!store.hasRepo || !bytes)
|
|
267
|
+
continue;
|
|
268
|
+
const copy = join(store.record, "cards", id, f);
|
|
269
|
+
if (existsSync(copy) && same(readFileSync(copy), bytes))
|
|
270
|
+
rmSync(copy);
|
|
271
|
+
}
|
|
272
|
+
if (store.hasRepo) {
|
|
273
|
+
const dir = join(store.record, "cards", id);
|
|
274
|
+
if (existsSync(dir) && readdirSync(dir).length === 0)
|
|
275
|
+
rmdirSync(dir);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Write a file the relay sent, as if it had been written here: an exclusive create. If this machine wrote other bytes at
|
|
281
|
+
* that path first, the relay's copy is everyone's, so it replaces this one, in the worktree's record too, and the agent
|
|
282
|
+
* is told: a revision was revised on another machine first, as the local API's 409 says of a stale verdict. A card's
|
|
283
|
+
* first revision means another machine's new card took this one's ID: this one is taken out, to be filed again.
|
|
284
|
+
*/
|
|
285
|
+
function put(store, rel, bytes, run) {
|
|
286
|
+
try {
|
|
287
|
+
store.storage.create(rel, bytes);
|
|
288
|
+
run.out.pulled.push(rel);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (!alreadyExists(e))
|
|
293
|
+
throw e;
|
|
294
|
+
}
|
|
295
|
+
const here = store.storage.read(rel);
|
|
296
|
+
if (here && same(here, bytes))
|
|
297
|
+
return;
|
|
298
|
+
const m = REVISION.exec(rel);
|
|
299
|
+
const taken = m?.[2] === "1" && !run.w.uploaded.has(rel) ? takeOut(store, m[1]) : undefined;
|
|
300
|
+
// Two machines found the same merge (D181), and said it differently (the cards each knew of, when): the relay's stands.
|
|
301
|
+
if (MERGE.test(rel)) {
|
|
302
|
+
store.storage.replace(rel, bytes);
|
|
303
|
+
run.out.replaced.push(rel);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (taken) {
|
|
307
|
+
run.refiles.push(taken);
|
|
308
|
+
store.storage.create(rel, bytes);
|
|
309
|
+
run.out.pulled.push(rel);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
store.storage.replace(rel, bytes);
|
|
313
|
+
if (store.hasRepo && here) {
|
|
314
|
+
const copy = join(store.record, ...rel.split("/"));
|
|
315
|
+
if (existsSync(copy) && same(readFileSync(copy), here))
|
|
316
|
+
writeFileSync(copy, bytes);
|
|
317
|
+
}
|
|
318
|
+
run.out.replaced.push(rel);
|
|
319
|
+
run.out.notes.push(m
|
|
320
|
+
? `relay: ${m[1]} r${m[2]} was revised on another machine first, so yours wasn't sent, and the relay's r${m[2]} replaces it here: read it with \`baesian show ${m[1]}\`, and if yours still applies, revise again once it's returned to you`
|
|
321
|
+
: `relay: ${rel} was written first by another machine, with other bytes: the relay's copy replaces this machine's`);
|
|
322
|
+
}
|
|
323
|
+
/** Apply one entry of the relay's log: its file, with its `.sig` written first, so no reader sees it signed but without one. */
|
|
324
|
+
function apply(store, e, run) {
|
|
325
|
+
let rel;
|
|
326
|
+
try {
|
|
327
|
+
rel = checkPath(e.path);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
run.out.notes.push(`relay: skipped an entry that isn't a path in the live layer (${JSON.stringify(e.path)})`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!PULLED.has(rel.split("/")[0]) || rel.endsWith(".sig") || rel.endsWith(".tmp")) {
|
|
334
|
+
run.out.notes.push(`relay: skipped ${rel}, which isn't a kind of file the relay holds`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Its body was removed by retention once it was delivered and its card finished (D137, B-9jqt4, B-ec3kj, M9f):
|
|
338
|
+
// nothing to write. What this machine already has, here or in the record, is unchanged; if it has neither, it's told.
|
|
339
|
+
if (e.body === undefined) {
|
|
340
|
+
if (!store.storage.read(rel) && !inRecord(store, rel))
|
|
341
|
+
run.missing.push(rel);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const bytes = Buffer.from(e.body, "base64");
|
|
345
|
+
if (bytes.byteLength !== e.size || sha256(bytes) !== e.digest) {
|
|
346
|
+
run.out.notes.push(`relay: skipped ${rel}, whose bytes don't match the digest its entry gives`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (e.sig !== undefined)
|
|
350
|
+
put(store, `${rel}.sig`, Buffer.from(e.sig, "base64"), run);
|
|
351
|
+
put(store, rel, bytes, run);
|
|
352
|
+
// An answer from before linking another machine uploaded (B-3zjjx) is one the relay has: this machine counts it only
|
|
353
|
+
// where its own record at the linking commit holds it (B-45xz8).
|
|
354
|
+
if (agentWritten(rel) || e.before_linking)
|
|
355
|
+
run.w.uploaded.add(rel);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Pull the log after the last sequence number pulled, page by page (`GET log`), into the live layer; then file again,
|
|
359
|
+
* under new IDs, the cards whose IDs another machine's cards took.
|
|
360
|
+
*/
|
|
361
|
+
async function pull(store, client, run) {
|
|
362
|
+
for (;;) {
|
|
363
|
+
const r = await client.request("GET", `log?after=${run.w.pulled}`);
|
|
364
|
+
if (r.status !== 200)
|
|
365
|
+
throw new RelayError(`the relay didn't give its log (${errorOf(r)})`);
|
|
366
|
+
const page = r.json;
|
|
367
|
+
// A log shorter than what this machine pulled is a project deleted with its owner's account and made again (B-b46z3,
|
|
368
|
+
// M9f): this machine starts afresh, so everything an agent wrote here goes up again.
|
|
369
|
+
if (typeof page?.head === "number" && page.head < run.w.pulled) {
|
|
370
|
+
run.out.notes.push(`relay: the project's log on ${client.service} is shorter than what this machine had pulled, so it was deleted and made again: this machine starts afresh, and sends everything its agents wrote`);
|
|
371
|
+
run.w.pulled = 0;
|
|
372
|
+
run.w.uploaded = new Set();
|
|
373
|
+
run.w.defaultBranch = undefined;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const entries = Array.isArray(page?.entries) ? page.entries : [];
|
|
377
|
+
for (const raw of entries) {
|
|
378
|
+
const e = readRelayEntry(raw);
|
|
379
|
+
apply(store, e, run);
|
|
380
|
+
run.w.pulled = Math.max(run.w.pulled, e.seq);
|
|
381
|
+
}
|
|
382
|
+
if (!page?.more || !entries.length)
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
const missing = run.missing.splice(0);
|
|
386
|
+
if (missing.length) {
|
|
387
|
+
const cards = [...new Set(missing.map((p) => /(B-[0-9a-hjkmnp-tv-z]{5})/.exec(p)?.[1]).filter((c) => c !== undefined))];
|
|
388
|
+
run.out.notes.push(`relay: ${missing.length === 1 ? "a file" : `${missing.length} files`}${cards.length ? ` of ${cards.join(", ")}` : ""} had been sent to the project's other machines and then removed from the relay, after the time it keeps them${cite("B-9jqt4", "B-ec3kj")}, before this machine pulled ${missing.length === 1 ? "it" : "them"}: ${missing.length === 1 ? "it isn't" : "they aren't"} here or in this branch's record; a branch where another machine committed ${missing.length === 1 ? "it" : "them"} has ${missing.length === 1 ? "it" : "them"}`);
|
|
389
|
+
}
|
|
390
|
+
for (const r of run.refiles.splice(0)) {
|
|
391
|
+
if (r.withdrawn) {
|
|
392
|
+
run.out.notes.push(`relay: ${r.id} was already taken on the relay by another machine's card; yours was withdrawn, so it isn't filed again`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const to = store.fileCard(r.draft, r.body, { agent: r.agent, model: r.model, force: true }).card.meta.id;
|
|
396
|
+
run.out.renamed.push({ from: r.id, to });
|
|
397
|
+
run.out.notes.push(`relay: ${r.id} was already taken on the relay by another machine's card, so yours is filed again as ${to}: use ${to} from now on (\`baesian show ${to}\`)`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The agent-written files the relay doesn't have yet, in the order it takes them (M9c): decks first, then card
|
|
402
|
+
* revisions, each card's in order, then the rest (the CLI's gate records, withdrawals, fallbacks, attachments), then the
|
|
403
|
+
* ledger's entries whose body names only the commit (`plainLedger()`) and the merges (D181). When a repo is first linked,
|
|
404
|
+
* that's every one in the live layer (B-6k0rk).
|
|
405
|
+
*/
|
|
406
|
+
export function unsent(store, uploaded) {
|
|
407
|
+
const list = (dir) => store.storage.list(dir).sort();
|
|
408
|
+
const decks = list("decks").map((f) => `decks/${f}`).filter((p) => DECK.test(p));
|
|
409
|
+
const revisions = [];
|
|
410
|
+
const rest = [];
|
|
411
|
+
for (const id of list("cards")) {
|
|
412
|
+
const files = list(`cards/${id}`).map((f) => `cards/${id}/${f}`);
|
|
413
|
+
const revs = files.flatMap((p) => {
|
|
414
|
+
const m = REVISION.exec(p);
|
|
415
|
+
return m ? [{ p, n: Number(m[2]) }] : [];
|
|
416
|
+
});
|
|
417
|
+
revisions.push(...revs.sort((a, b) => a.n - b.n).map((r) => r.p));
|
|
418
|
+
rest.push(...files.filter((p) => CARD_FILE.test(p)));
|
|
419
|
+
}
|
|
420
|
+
rest.push(...list("attachments").map((f) => `attachments/${f}`).filter((p) => ATTACHMENT.test(p)));
|
|
421
|
+
const ledger = list("ledger").map((f) => `ledger/${f}`).filter((p) => LEDGER.test(p) && !uploaded.has(p) && plainLedger(store.storage.read(p) ?? new Uint8Array()));
|
|
422
|
+
const merges = list("merges").map((f) => `merges/${f}`).filter((p) => MERGE.test(p));
|
|
423
|
+
return [...decks, ...revisions, ...rest, ...ledger, ...merges].filter((p) => !uploaded.has(p));
|
|
424
|
+
}
|
|
425
|
+
/** The folders of what a person writes: answers, rules, topic merges and preference files (Hosted Service section 3). */
|
|
426
|
+
const ANSWERS = ["verdicts", "rules", "topics", "preferences"];
|
|
427
|
+
/**
|
|
428
|
+
* Whether someone has answered on the website, as far as this machine has pulled: anything in the answers' folders with
|
|
429
|
+
* the service's signature. The relay then takes no more answers from before linking (B-3zjjx), so none are offered.
|
|
430
|
+
*/
|
|
431
|
+
function answeredOnWebsite(store) {
|
|
432
|
+
return ANSWERS.some((dir) => store.storage.list(dir).some((f) => f.endsWith(".sig")));
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The answers, rules, topic merges and preference files from before linking that the relay doesn't have yet (B-3zjjx):
|
|
436
|
+
* those that count here as B-45xz8 counts them, in the order it takes them. None once someone has answered on the website.
|
|
437
|
+
*/
|
|
438
|
+
export function unsentBeforeLinking(store, uploaded) {
|
|
439
|
+
if (answeredOnWebsite(store))
|
|
440
|
+
return [];
|
|
441
|
+
return store.answersFromBeforeLinking().filter((p) => !uploaded.has(p));
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Push what the relay doesn't have yet: what agents wrote, then, at first link, the answers from before linking
|
|
445
|
+
* (B-3zjjx). A file another machine wrote first, with other bytes, is refused (409); the pull that follows brings the
|
|
446
|
+
* relay's copy, and tells the agent (`put()`). A card whose text the relay removed (410, M9f) takes nothing more: its
|
|
447
|
+
* files stay here and in the record, and aren't offered again. Anything else refused stays here, and is offered again at
|
|
448
|
+
* the next sync. Returns whether anything was refused for being written elsewhere first.
|
|
449
|
+
*/
|
|
450
|
+
async function push(store, client, run) {
|
|
451
|
+
const blocked = new Set();
|
|
452
|
+
const removed = new Set();
|
|
453
|
+
let conflicted = false;
|
|
454
|
+
const files = unsent(store, run.w.uploaded);
|
|
455
|
+
const early = unsentBeforeLinking(store, run.w.uploaded);
|
|
456
|
+
if (files.length + early.length)
|
|
457
|
+
run.sending?.(files.length + early.length);
|
|
458
|
+
for (const rel of files) {
|
|
459
|
+
const card = cardOf(rel);
|
|
460
|
+
if (card && removed.has(card)) {
|
|
461
|
+
run.w.uploaded.add(rel);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (card && blocked.has(card))
|
|
465
|
+
continue;
|
|
466
|
+
const bytes = store.storage.read(rel);
|
|
467
|
+
if (!bytes)
|
|
468
|
+
continue;
|
|
469
|
+
const r = await client.request("PUT", `files/${encodePath(rel)}`, bytes);
|
|
470
|
+
if (r.status === 200 || r.status === 201) {
|
|
471
|
+
run.w.uploaded.add(rel);
|
|
472
|
+
run.out.pushed.push(rel);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (r.status === 410) {
|
|
476
|
+
// The relay removed this card's text after the time it keeps it (M9f): it takes nothing more for the card. Its files
|
|
477
|
+
// stay here and in the record, and aren't offered again.
|
|
478
|
+
run.w.uploaded.add(rel);
|
|
479
|
+
if (card)
|
|
480
|
+
removed.add(card);
|
|
481
|
+
run.out.notes.push(`relay: ${card ?? rel}'s text was removed from the relay after the time it keeps it${cite("B-9jqt4", "B-ec3kj")}, so what's new for it stays here and in the repo's record, and the website won't show it`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (card)
|
|
485
|
+
blocked.add(card);
|
|
486
|
+
if (r.status === 503)
|
|
487
|
+
throw new RelayError(`the relay can't take files just now (${errorOf(r)})`);
|
|
488
|
+
if (r.status === 409)
|
|
489
|
+
conflicted = true;
|
|
490
|
+
else
|
|
491
|
+
run.out.notes.push(`relay: refused ${rel} (${errorOf(r)}); it stays here, and is offered again at the next sync`);
|
|
492
|
+
}
|
|
493
|
+
for (const rel of early) {
|
|
494
|
+
const bytes = store.storage.read(rel);
|
|
495
|
+
if (!bytes)
|
|
496
|
+
continue;
|
|
497
|
+
const r = await client.request("PUT", `files/${encodePath(rel)}`, bytes);
|
|
498
|
+
if (r.status === 200 || r.status === 201) {
|
|
499
|
+
run.w.uploaded.add(rel);
|
|
500
|
+
run.out.pushed.push(rel);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (r.status === 503)
|
|
504
|
+
throw new RelayError(`the relay can't take files just now (${errorOf(r)})`);
|
|
505
|
+
if (r.json?.before_linking === "closed") {
|
|
506
|
+
run.out.notes.push(`relay: someone has answered on the website, so the answers from before linking that hadn't gone up stay in git, where they still count${cite("B-45xz8")}; the website shows their cards as waiting${cite("B-3zjjx")}`);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
run.out.notes.push(`relay: refused ${rel}, from before linking (${errorOf(r)}); it stays here, and is offered again at the next sync`);
|
|
510
|
+
}
|
|
511
|
+
await pushDefaultBranch(store, client, run);
|
|
512
|
+
return conflicted;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Send the repo's default branch (B-jr8mk), as git says it here (`Store.defaultBranch`), when it isn't what this machine
|
|
516
|
+
* last sent: `default-branch.json`, only its name, which the relay keeps in place of the one before, for the website's
|
|
517
|
+
* branches panel. Quietly: one the relay doesn't take (an older relay's) is offered again at the next sync, and it's
|
|
518
|
+
* never counted among the files sent. Only a machine whose reading changed sends it, so two that read it differently
|
|
519
|
+
* don't take turns.
|
|
520
|
+
*/
|
|
521
|
+
async function pushDefaultBranch(store, client, run) {
|
|
522
|
+
if (!store.hasRepo)
|
|
523
|
+
return;
|
|
524
|
+
const branch = store.defaultBranch();
|
|
525
|
+
if (!branch || branch === run.w.defaultBranch)
|
|
526
|
+
return;
|
|
527
|
+
let bytes;
|
|
528
|
+
try {
|
|
529
|
+
bytes = encode(`${JSON.stringify(asWritten("default-branch", DefaultBranch.parse({ type: "default-branch", format: newestFormat("default-branch"), branch })))}\n`);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return; // not a name git allows: nothing to send
|
|
533
|
+
}
|
|
534
|
+
const r = await client.request("PUT", `files/${DEFAULT_BRANCH}`, bytes);
|
|
535
|
+
if (r.status === 200 || r.status === 201)
|
|
536
|
+
run.w.defaultBranch = branch;
|
|
537
|
+
}
|
|
538
|
+
/** Pull, push, or both, with this client, keeping `relay.json` up to date with whatever got done. */
|
|
539
|
+
async function syncWith(store, client, o) {
|
|
540
|
+
const s = readRelayState(store, client.service, client.project);
|
|
541
|
+
const run = { w: { service: s.service, project: s.project, pulled: s.pulled, uploaded: new Set(s.uploaded), defaultBranch: s.default_branch }, out: report("synced"), refiles: [], missing: [], ...(o.sending ? { sending: o.sending } : {}) };
|
|
542
|
+
try {
|
|
543
|
+
if (o.pull)
|
|
544
|
+
await pull(store, client, run);
|
|
545
|
+
// A push refused for what another machine wrote first pulls the relay's copy, then pushes again: a card filed again
|
|
546
|
+
// under a new ID goes up in the next round.
|
|
547
|
+
if (o.push)
|
|
548
|
+
for (let round = 0; round < 3 && (await push(store, client, run)); round++)
|
|
549
|
+
await pull(store, client, run);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
run.out.state = e instanceof SignedOut ? "signed-out" : "offline";
|
|
553
|
+
run.out.notes.push(failureNote(e, client.service));
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
saveState(store, run.w);
|
|
557
|
+
}
|
|
558
|
+
return run.out;
|
|
559
|
+
}
|
|
560
|
+
/** What the agent is told when a sync couldn't finish. */
|
|
561
|
+
function failureNote(e, service) {
|
|
562
|
+
if (e instanceof Offline)
|
|
563
|
+
return `relay: ${e.message}. Everything here works as before; what's new goes up at the next command that reaches the relay.`;
|
|
564
|
+
if (e instanceof SignedOut)
|
|
565
|
+
return `relay: ${e.message}. Ask the person to run \`baesian login\` here; until then nothing goes to the reviewer or comes back.`;
|
|
566
|
+
if (e instanceof TooOld)
|
|
567
|
+
return `relay: ${service} refused this Baesian${cite("B-4p43b")}: ${e.message} Everything here works as before; until it's upgraded, nothing goes to the reviewer or comes back.`;
|
|
568
|
+
return `relay: syncing with ${service} stopped: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Sync this repo with its relay, if it's linked: pull, then push. Never throws for the network, the grant or the relay:
|
|
572
|
+
* what went wrong is in the report's notes, and the live layer works as before. A repo that isn't linked does nothing.
|
|
573
|
+
*/
|
|
574
|
+
export async function syncRelay(store, o = {}) {
|
|
575
|
+
const link = relayFor(store, o);
|
|
576
|
+
if (link.kind === "off")
|
|
577
|
+
return report("off");
|
|
578
|
+
if (link.kind !== "on")
|
|
579
|
+
return report(link.kind, [link.why]);
|
|
580
|
+
return syncWith(store, link.client, { pull: o.pull ?? true, push: o.push ?? true, ...(o.sending ? { sending: o.sending } : {}) });
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* A filing command (Hosted Service section 1): pull first, so the filing check sees other machines' open cards (D36),
|
|
584
|
+
* then run it, then push what it wrote. When the pull couldn't reach the relay, the push isn't tried: the command doesn't
|
|
585
|
+
* wait twice. Returns what the command returned, and the notes for the agent.
|
|
586
|
+
*/
|
|
587
|
+
export async function aroundFiling(store, op, http = {}) {
|
|
588
|
+
const before = await syncRelay(store, { ...http, push: false });
|
|
589
|
+
const value = await op();
|
|
590
|
+
const after = before.state === "synced" ? await syncRelay(store, { ...http, pull: false }) : undefined;
|
|
591
|
+
return { value, notes: [...before.notes, ...(after?.notes ?? [])] };
|
|
592
|
+
}
|
|
593
|
+
// ---------------------------------------------------------------- waiting on the relay (B-bv49e)
|
|
594
|
+
/** How long a closed or failed live socket waits before it's opened again, by attempt. */
|
|
595
|
+
const RECONNECT_MS = [1000, 2000, 5000, 10_000];
|
|
596
|
+
/**
|
|
597
|
+
* How often a wait pulls without being told to (B-xnae9): a wait in the background may run for hours, over a laptop's
|
|
598
|
+
* sleep or a socket that died without closing, and a `changed` message missed then would leave it waiting on an answer
|
|
599
|
+
* already given.
|
|
600
|
+
*/
|
|
601
|
+
export const RESYNC_MS = 5 * 60_000;
|
|
602
|
+
/** Several markers refreshed at one beat read the reviewer's presence once. */
|
|
603
|
+
const PRESENCE_GAP_MS = 5000;
|
|
604
|
+
/**
|
|
605
|
+
* While `baesian wait` runs in a linked repo: the relay's live socket, `GET /v1/projects/{p}/live`, whose `changed`
|
|
606
|
+
* messages each start a pull, so an answer reaches the live layer within a moment and the wait's own poll finds it; the
|
|
607
|
+
* waiting markers, sent up with each beat and removed when their cards are answered or the wait ends (D90); and the
|
|
608
|
+
* reviewer's presence from the relay's state, written into the live layer when it's newer, so the wait knows when they're
|
|
609
|
+
* away (D41). A socket opened again pulls at once, for what changed while it was closed; and it pulls every `RESYNC_MS`
|
|
610
|
+
* besides. Until a sync both ways has gone through (`unsynced`: the wait started offline), each pull pushes too, so the
|
|
611
|
+
* cards reach the reviewer.
|
|
612
|
+
*/
|
|
613
|
+
class Listener {
|
|
614
|
+
store;
|
|
615
|
+
client;
|
|
616
|
+
unsynced;
|
|
617
|
+
ws;
|
|
618
|
+
closed = false;
|
|
619
|
+
opened = false;
|
|
620
|
+
attempts = 0;
|
|
621
|
+
timer;
|
|
622
|
+
resync;
|
|
623
|
+
pulling;
|
|
624
|
+
again = false;
|
|
625
|
+
presenceAt = 0;
|
|
626
|
+
queue = Promise.resolve();
|
|
627
|
+
notes = [];
|
|
628
|
+
constructor(store, client, unsynced = false) {
|
|
629
|
+
this.store = store;
|
|
630
|
+
this.client = client;
|
|
631
|
+
this.unsynced = unsynced;
|
|
632
|
+
}
|
|
633
|
+
/** Open the socket. A failure is retried until the wait ends; the wait goes on either way. */
|
|
634
|
+
async open() {
|
|
635
|
+
if (this.closed)
|
|
636
|
+
return;
|
|
637
|
+
if (!this.resync) {
|
|
638
|
+
this.resync = setInterval(() => this.pull(), RESYNC_MS);
|
|
639
|
+
this.resync.unref?.();
|
|
640
|
+
}
|
|
641
|
+
let token;
|
|
642
|
+
try {
|
|
643
|
+
token = await this.client.token();
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
this.note(e);
|
|
647
|
+
return this.retry();
|
|
648
|
+
}
|
|
649
|
+
if (this.closed)
|
|
650
|
+
return;
|
|
651
|
+
const ws = new WebSocket(this.client.liveUrl(), { headers: { authorization: `Bearer ${token}`, [RELEASE_HEADER]: VERSION } });
|
|
652
|
+
this.ws = ws;
|
|
653
|
+
ws.onopen = () => {
|
|
654
|
+
this.attempts = 0;
|
|
655
|
+
// Opened again, after a close: what changed meanwhile sent no message here. Or the first sync didn't go through.
|
|
656
|
+
if (this.opened || this.unsynced)
|
|
657
|
+
this.pull();
|
|
658
|
+
this.opened = true;
|
|
659
|
+
};
|
|
660
|
+
ws.onmessage = (m) => {
|
|
661
|
+
try {
|
|
662
|
+
if (JSON.parse(String(m.data)).type === "changed")
|
|
663
|
+
this.pull();
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// not a message the relay sends: ignored
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
ws.onclose = () => {
|
|
670
|
+
if (this.ws === ws)
|
|
671
|
+
this.ws = undefined;
|
|
672
|
+
this.retry();
|
|
673
|
+
};
|
|
674
|
+
ws.onerror = () => {
|
|
675
|
+
// onclose follows, and retries
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
retry() {
|
|
679
|
+
if (this.closed || this.timer)
|
|
680
|
+
return;
|
|
681
|
+
const ms = RECONNECT_MS[Math.min(this.attempts++, RECONNECT_MS.length - 1)];
|
|
682
|
+
this.timer = setTimeout(() => {
|
|
683
|
+
this.timer = undefined;
|
|
684
|
+
void this.open();
|
|
685
|
+
}, ms);
|
|
686
|
+
}
|
|
687
|
+
note(e) {
|
|
688
|
+
const line = failureNote(e, this.client.service);
|
|
689
|
+
if (!this.notes.includes(line))
|
|
690
|
+
this.notes.push(line);
|
|
691
|
+
}
|
|
692
|
+
/** Pull now, or once more after the pull that's running: changes that come while pulling aren't missed. */
|
|
693
|
+
pull() {
|
|
694
|
+
if (this.pulling) {
|
|
695
|
+
this.again = true;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.pulling = (async () => {
|
|
699
|
+
do {
|
|
700
|
+
this.again = false;
|
|
701
|
+
const both = this.unsynced;
|
|
702
|
+
const r = await syncWith(this.store, this.client, { pull: true, push: both });
|
|
703
|
+
if (both && r.state === "synced")
|
|
704
|
+
this.unsynced = false;
|
|
705
|
+
for (const n of r.notes)
|
|
706
|
+
if (!this.notes.includes(n))
|
|
707
|
+
this.notes.push(n);
|
|
708
|
+
} while (this.again && !this.closed);
|
|
709
|
+
})().finally(() => {
|
|
710
|
+
this.pulling = undefined;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/** A waiting marker, written or refreshed (PUT, with the presence read afresh) or removed (DELETE), in order. */
|
|
714
|
+
marker(m) {
|
|
715
|
+
this.queue = this.queue
|
|
716
|
+
.then(async () => {
|
|
717
|
+
if (m.removed) {
|
|
718
|
+
await this.client.request("DELETE", `files/${encodePath(m.path)}`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const bytes = this.store.storage.read(m.path);
|
|
722
|
+
if (bytes)
|
|
723
|
+
await this.client.request("PUT", `files/${encodePath(m.path)}`, bytes);
|
|
724
|
+
await this.presence();
|
|
725
|
+
})
|
|
726
|
+
.catch((e) => this.note(e));
|
|
727
|
+
}
|
|
728
|
+
/** The reviewer's presence on the relay (`GET state`), into the live layer when it's newer than what's there. */
|
|
729
|
+
async presence() {
|
|
730
|
+
if (Date.now() - this.presenceAt < PRESENCE_GAP_MS)
|
|
731
|
+
return;
|
|
732
|
+
this.presenceAt = Date.now();
|
|
733
|
+
const r = await this.client.request("GET", "state");
|
|
734
|
+
if (r.status !== 200)
|
|
735
|
+
return;
|
|
736
|
+
const p = Presence.safeParse(r.json?.presence);
|
|
737
|
+
if (!p.success)
|
|
738
|
+
return;
|
|
739
|
+
const here = this.store.presence();
|
|
740
|
+
if (here && Date.parse(here.at) >= Date.parse(p.data.at))
|
|
741
|
+
return;
|
|
742
|
+
this.store.storage.replace("presence.json", encode(`${JSON.stringify(asWritten("presence", p.data))}\n`));
|
|
743
|
+
}
|
|
744
|
+
/** Close the socket and stop retrying; let the markers' removal and a pull in progress finish, for a few seconds at most. */
|
|
745
|
+
async close() {
|
|
746
|
+
this.closed = true;
|
|
747
|
+
if (this.timer)
|
|
748
|
+
clearTimeout(this.timer);
|
|
749
|
+
this.timer = undefined;
|
|
750
|
+
if (this.resync)
|
|
751
|
+
clearInterval(this.resync);
|
|
752
|
+
this.resync = undefined;
|
|
753
|
+
try {
|
|
754
|
+
this.ws?.close();
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// already closing
|
|
758
|
+
}
|
|
759
|
+
let limit;
|
|
760
|
+
await Promise.race([Promise.all([this.queue, this.pulling]), new Promise((done) => (limit = setTimeout(done, 3000)))]);
|
|
761
|
+
clearTimeout(limit);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* `baesian wait` in a linked repo (B-bv49e): sync first, so the cards are on the relay and any answer already given is
|
|
766
|
+
* here; then run the wait it's given (`waitOp`, or `waitAllOp` on several cards, B-xnae9), with the relay's live socket
|
|
767
|
+
* starting a pull at each change, and the waiting markers going up as a heartbeat. Offline or signed out, it waits on the
|
|
768
|
+
* live layer alone, as before; but a wait with no time limit (`WaitOptions.untilAnswered`) that starts offline listens
|
|
769
|
+
* all the same, syncing once the socket opens, since it may run for hours.
|
|
770
|
+
*/
|
|
771
|
+
export async function waitWithRelay(store, wait, opts, http = {}) {
|
|
772
|
+
const link = relayFor(store, http);
|
|
773
|
+
if (link.kind === "off")
|
|
774
|
+
return { value: await wait(opts), notes: [] };
|
|
775
|
+
if (link.kind !== "on")
|
|
776
|
+
return { value: await wait(opts), notes: [link.why] };
|
|
777
|
+
const first = await syncWith(store, link.client, { pull: true, push: true });
|
|
778
|
+
const listen = first.state === "synced" || (first.state === "offline" && opts.untilAnswered === true);
|
|
779
|
+
if (!listen)
|
|
780
|
+
return { value: await wait(opts), notes: first.notes };
|
|
781
|
+
const live = new Listener(store, link.client, first.state !== "synced");
|
|
782
|
+
await live.open();
|
|
783
|
+
try {
|
|
784
|
+
const value = await wait({
|
|
785
|
+
...opts,
|
|
786
|
+
onMarker: (m) => {
|
|
787
|
+
live.marker(m);
|
|
788
|
+
opts.onMarker?.(m);
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
return { value, notes: [...first.notes, ...live.notes] };
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
await live.close();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=sync.js.map
|