@augurworks/augur 0.15.3 → 0.15.5
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/agents/README.md +3 -1
- package/agents/drafts.md +3 -0
- package/agents/publishing.md +0 -4
- package/package.json +1 -1
- package/scripts/connect.mjs +64 -4
- package/scripts/hook.mjs +2 -1
- package/scripts/land.mjs +4 -1
- package/scripts/lib/draft.mjs +54 -6
- package/scripts/open.mjs +2 -1
- package/scripts/read.mjs +11 -3
- package/scripts/save.mjs +3 -1
- package/scripts/sync.mjs +3 -1
- package/src/_worker.js +26 -6
- package/src/unit-object.mjs +22 -4
package/agents/README.md
CHANGED
|
@@ -25,7 +25,9 @@ npx @augurworks/augur connect --origin https://<the workspace>
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
It prints one line to relay — *ask the owner of this workspace to open `<link>` and
|
|
28
|
-
enter `<code>`* — and waits.
|
|
28
|
+
enter `<code>`* — and waits. If your person reads you through messages rather than a
|
|
29
|
+
terminal, run it with `--no-wait`: it prints the line and exits, and running it again
|
|
30
|
+
after they approve collects the token for that same code. The owner opens the link in a browser they are already
|
|
29
31
|
signed in to and types the code; the token lands in `~/.config/augur/tokens.json`, and
|
|
30
32
|
`augur ship` / `augur publish` use it from then on. `publish` runs the pairing itself when
|
|
31
33
|
it finds no token, so inside a workspace tree nothing has to be done first. On a workspace
|
package/agents/drafts.md
CHANGED
|
@@ -78,6 +78,9 @@ you do not want (its saves stay on the instance for a while; nothing else is tou
|
|
|
78
78
|
|---|---|---|
|
|
79
79
|
| `main-moved` / "sync first" | somebody landed since you opened | `augur sync`, then `augur land` |
|
|
80
80
|
| `stale-draft` | another process saved to this same draft | `augur sync`, then `augur save` |
|
|
81
|
+
| `draft-closed` | somebody landed or discarded this draft from the site (it says who and when) | your edits are still in the folder; `augur open` the prototype again and copy them in |
|
|
82
|
+
| `manifest-contended` | many landings hit the workspace in the same second; `land` already tried again | `augur land` once more |
|
|
83
|
+
| `forbidden` with "run `augur connect` again" | this machine's token was revoked (a role change or a removal) or belongs to another workspace | `augur connect` |
|
|
81
84
|
| `would-unpublish` | the draft has no files (the folder is empty) | check the folder; a deletion is its own verb |
|
|
82
85
|
| `not-a-prototype-folder` / `reserved-folder` | the path is not `<opportunity>/<prototype>` | name the prototype folder |
|
|
83
86
|
| `units-not-configured` | this instance does not serve drafts | `augur publish` — see publishing.md |
|
package/agents/publishing.md
CHANGED
|
@@ -229,11 +229,7 @@ them.
|
|
|
229
229
|
purpose), say so:
|
|
230
230
|
|
|
231
231
|
```
|
|
232
|
-
<<<<<<< HEAD
|
|
233
|
-
node ../augur/scripts/publish.mjs --allow-unpublish
|
|
234
|
-
=======
|
|
235
232
|
augur publish --allow-unpublish
|
|
236
|
-
>>>>>>> 01da41b5 (npm: the engine speaks as a package — hooks, self-update and every contract)
|
|
237
233
|
```
|
|
238
234
|
|
|
239
235
|
Adding pages is never blocked; only losing them is.
|
package/package.json
CHANGED
package/scripts/connect.mjs
CHANGED
|
@@ -30,6 +30,28 @@ const ORIGIN = (opt("--origin") || process.env.AUGUR_ORIGIN || resolveOrigin() |
|
|
|
30
30
|
if (!ORIGIN) die("no origin — pass --origin https://your.site, or set AUGUR_ORIGIN.");
|
|
31
31
|
|
|
32
32
|
const POLL_MS = 2000;
|
|
33
|
+
// `--no-wait`: print the line to relay and exit at once. For an agent that talks to its
|
|
34
|
+
// person through messages rather than a terminal — a chat, a ticket, a print-mode run —
|
|
35
|
+
// waiting here is a dead end: the person cannot see the code until the command ends, and
|
|
36
|
+
// the command does not end until the person acts. The pairing is kept on this machine, so
|
|
37
|
+
// `augur connect` run again after the approval collects the token, minting no new code.
|
|
38
|
+
const NO_WAIT = argv.includes("--no-wait");
|
|
39
|
+
const PENDING_FILE = path.join(os.homedir(), ".config", "augur", "pairing.json");
|
|
40
|
+
const host = new URL(ORIGIN).host;
|
|
41
|
+
function readPending() {
|
|
42
|
+
try {
|
|
43
|
+
const all = JSON.parse(readFileSync(PENDING_FILE, "utf8"));
|
|
44
|
+
const p = all[host];
|
|
45
|
+
return p && p.code && p.deviceSecret && Date.parse(p.expiresAt) > Date.now() ? p : null;
|
|
46
|
+
} catch (e) { return null; }
|
|
47
|
+
}
|
|
48
|
+
function writePending(p) {
|
|
49
|
+
mkdirSync(path.dirname(PENDING_FILE), { recursive: true });
|
|
50
|
+
let all = {};
|
|
51
|
+
try { all = JSON.parse(readFileSync(PENDING_FILE, "utf8")); } catch (e) {}
|
|
52
|
+
if (p) all[host] = p; else delete all[host];
|
|
53
|
+
writeFileSync(PENDING_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
54
|
+
}
|
|
33
55
|
|
|
34
56
|
async function post(pathPart, body) {
|
|
35
57
|
const r = await fetch(`${ORIGIN}/__publish/_pair/${pathPart}`, {
|
|
@@ -42,7 +64,22 @@ async function post(pathPart, body) {
|
|
|
42
64
|
return { status: r.status, json };
|
|
43
65
|
}
|
|
44
66
|
|
|
45
|
-
|
|
67
|
+
// A pairing this machine already started and nobody has collected: ask once whether it
|
|
68
|
+
// was approved meanwhile, and if not, keep waiting on THAT code rather than minting a
|
|
69
|
+
// second one for the same person to type.
|
|
70
|
+
const pending = readPending();
|
|
71
|
+
if (pending) {
|
|
72
|
+
const res = await post("claim", { code: pending.code, deviceSecret: pending.deviceSecret });
|
|
73
|
+
if (res.status === 200 && res.json && res.json.token) {
|
|
74
|
+
writePending(null);
|
|
75
|
+
finish(res.json);
|
|
76
|
+
} else if (res.status === 202) {
|
|
77
|
+
log(`the pairing started earlier is still waiting to be approved.`);
|
|
78
|
+
} else {
|
|
79
|
+
writePending(null);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const start = readPending() ? { status: 200, json: { ...readPending(), expiresInMs: Date.parse(readPending().expiresAt) - Date.now() } } : await post("start");
|
|
46
83
|
if (start.status === 429) die("too many attempts from here. Wait a few minutes.");
|
|
47
84
|
if (start.status !== 200 || !start.json || !start.json.code) {
|
|
48
85
|
// The routes answer as though they are not there when the instance has not opted in,
|
|
@@ -53,8 +90,9 @@ if (start.status !== 200 || !start.json || !start.json.code) {
|
|
|
53
90
|
}
|
|
54
91
|
|
|
55
92
|
const { code, deviceSecret, approveUrl, expiresInMs } = start.json;
|
|
56
|
-
const mins = Math.round((expiresInMs || 300000) / 60000);
|
|
93
|
+
const mins = Math.max(1, Math.round((expiresInMs || 300000) / 60000));
|
|
57
94
|
const pretty = code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
|
|
95
|
+
if (!readPending()) writePending({ code, deviceSecret, approveUrl, startedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + (expiresInMs || 300000)).toISOString() });
|
|
58
96
|
|
|
59
97
|
// Written to be RELAYED, not read: the usual runner of this command is an agent, which
|
|
60
98
|
// pastes the first line to a person. So the first line is the whole instruction, and it
|
|
@@ -68,6 +106,22 @@ console.log(` The code is good for ${mins} minutes and only for this terminal.
|
|
|
68
106
|
console.log("");
|
|
69
107
|
console.log(` ${C.warn}If you did not just run this command, do not approve it.${C.off}`);
|
|
70
108
|
console.log("");
|
|
109
|
+
if (NO_WAIT) {
|
|
110
|
+
console.log(` ${C.dim}Not waiting. Once they have approved, run \`augur connect\` again here: it collects the`);
|
|
111
|
+
console.log(` token for this same code and mints no new one.${C.off}`);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
console.log(` ${C.dim}You can stop waiting (Ctrl-C) and run \`augur connect\` again after they approve —`);
|
|
115
|
+
console.log(` it collects the token for this same code.${C.off}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
// Addressed to an agent, in the second person, because the polite version above was
|
|
118
|
+
// read and ignored: an agent whose person only sees its messages once it finishes its
|
|
119
|
+
// turn kept polling here for the code's whole life, twice, and nobody ever saw the code.
|
|
120
|
+
console.log(` ${C.warn}AGENTS: if your person reads your messages only after you finish, do not wait here.`);
|
|
121
|
+
console.log(` Stop now, send them the first line above, and run \`augur connect\` again once they say`);
|
|
122
|
+
console.log(` they have approved — it collects the token then. (\`augur connect --no-wait\` does`);
|
|
123
|
+
console.log(` exactly that in one step.)${C.off}`);
|
|
124
|
+
console.log("");
|
|
71
125
|
|
|
72
126
|
const deadline = Date.now() + (expiresInMs || 300000);
|
|
73
127
|
let saved = null;
|
|
@@ -76,9 +130,13 @@ while (Date.now() < deadline) {
|
|
|
76
130
|
const res = await post("claim", { code, deviceSecret });
|
|
77
131
|
if (res.status === 200 && res.json && res.json.token) { saved = res.json; break; }
|
|
78
132
|
if (res.status === 202) continue; // still waiting for somebody to approve
|
|
79
|
-
if (res.status === 404) die("this pairing is no longer valid. Run `augur connect` again.");
|
|
133
|
+
if (res.status === 404) { writePending(null); die("this pairing is no longer valid. Run `augur connect` again."); }
|
|
80
134
|
}
|
|
81
|
-
if (!saved) die(`nobody approved it within ${mins} minutes. Run \`augur connect\` again.`);
|
|
135
|
+
if (!saved) { writePending(null); die(`nobody approved it within ${mins} minutes. Run \`augur connect\` again.`); }
|
|
136
|
+
writePending(null);
|
|
137
|
+
finish(saved);
|
|
138
|
+
|
|
139
|
+
function finish(saved) {
|
|
82
140
|
|
|
83
141
|
const dir = path.join(os.homedir(), ".config", "augur");
|
|
84
142
|
mkdirSync(dir, { recursive: true });
|
|
@@ -100,4 +158,6 @@ if (saved.expiresAt) {
|
|
|
100
158
|
console.log(`${C.dim}It expires in ${days} days (${saved.expiresAt.slice(0, 10)}). Run \`augur connect\` again then.${C.off}`);
|
|
101
159
|
} else {
|
|
102
160
|
console.log(`${C.dim}It expires on its own. Run \`augur connect\` again when it does.${C.off}`);
|
|
161
|
+
}
|
|
162
|
+
process.exit(0);
|
|
103
163
|
}
|
package/scripts/hook.mjs
CHANGED
|
@@ -63,5 +63,6 @@ const client = unitClient({ origin, token, space: st.space, session: st.session
|
|
|
63
63
|
const r = await doSave({ client, dir: target.dir });
|
|
64
64
|
if (r.ok) process.exit(0);
|
|
65
65
|
if (r.error === "stale-draft" || r.error === "stale-draft-revision") refuse(`draft ${st.draftId} not saved: it moved under you (another process saved to it) — run augur sync, then augur save.`);
|
|
66
|
+
if (r.error === "draft-closed") refuse(`draft ${st.draftId} not saved: it was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — this folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
|
|
66
67
|
if (r.error === "network") refuse(`draft ${st.draftId} not saved: ${origin} is unreachable (${r.message}). Nothing is lost — the next save carries every change since.`);
|
|
67
|
-
refuse(`draft ${st.draftId} not saved: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}. Fix it and run augur save.`);
|
|
68
|
+
refuse(`draft ${st.draftId} not saved: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}${r.message ? ` — ${r.message}` : ""}. Fix it and run augur save.`);
|
package/scripts/land.mjs
CHANGED
|
@@ -26,7 +26,10 @@ if (!r.ok) {
|
|
|
26
26
|
die("run `augur sync` to fold those in, check the draft address, then `augur land` again.");
|
|
27
27
|
}
|
|
28
28
|
if (r.error === "landing-in-progress") die("somebody is landing this prototype right now — try again in a few seconds.");
|
|
29
|
-
|
|
29
|
+
if (r.error === "manifest-contended") die("the workspace was busy landing other prototypes — nothing changed; run augur land again.");
|
|
30
|
+
if (r.error === "draft-closed") die(`this draft was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — the folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
|
|
31
|
+
if (r.error === "network") die(`could not reach the instance (${r.message}). Nothing is lost — run augur land again.`);
|
|
32
|
+
die(`land refused: ${r.error || r.status}${r.message ? ` — ${r.message}` : ""}`);
|
|
30
33
|
}
|
|
31
34
|
// The bytes are live; when `recorded` is false only the history entry is missing. Said out
|
|
32
35
|
// loud because the next call adopts that landing as the instance's own, and nobody would
|
package/scripts/lib/draft.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import { merge3 } from "./merge3.mjs";
|
|
12
12
|
|
|
13
13
|
export const STATE_FILE = ".augur/draft.json";
|
|
14
|
+
/** How many times `land` tries again when the space's manifest is contended. */
|
|
15
|
+
export const LAND_RETRIES = 4;
|
|
14
16
|
export const THEIRS_DIR = ".augur/theirs";
|
|
15
17
|
// The machine-wide registry of open draft folders. `AUGUR_DRAFTS_REGISTRY` exists for the
|
|
16
18
|
// test suite, which must never write into the developer's own home folder.
|
|
@@ -111,17 +113,30 @@ export const registryList = () => readRegistry().drafts;
|
|
|
111
113
|
* `/__unit/<verb>` and blobs at `/__publish/<space>/blob/<hash>`. Non-2xx answers come back
|
|
112
114
|
* as `{status, ...body}` rather than throwing, because a 409 is an answer, not a failure.
|
|
113
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* The server answers a token it does not know with a bare `forbidden` and no sentence,
|
|
118
|
+
* ON PURPOSE — a guessed token must learn nothing. This side knows something the server
|
|
119
|
+
* will not say: the token it just sent is the one saved on this machine, so a bare
|
|
120
|
+
* refusal means that token is dead here — revoked by a role change or a removal, or
|
|
121
|
+
* minted for another workspace — and the way back is to pair again.
|
|
122
|
+
*/
|
|
123
|
+
export const TOKEN_NOT_ACCEPTED = "this machine's publish token is not accepted here — it may have been revoked (a role change or a removal does that), or it belongs to another workspace. Run `augur connect` again.";
|
|
124
|
+
export function explainRefusal(body, status) {
|
|
125
|
+
if (status === 403 && body && body.error === "forbidden" && !body.message) return { ...body, message: TOKEN_NOT_ACCEPTED };
|
|
126
|
+
return body;
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
export function unitClient({ origin, token, space, session }) {
|
|
115
130
|
const headers = { Authorization: `Bearer ${token}`, "X-Augur-Session": session || "" };
|
|
116
131
|
const post = async (verb, body) => {
|
|
117
132
|
const r = await fetch(`${origin}/__unit/${verb}`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
118
133
|
const out = await r.json().catch(() => ({}));
|
|
119
|
-
return r.ok ? out : { status: r.status, ...out };
|
|
134
|
+
return r.ok ? out : { status: r.status, ...explainRefusal(out, r.status) };
|
|
120
135
|
};
|
|
121
136
|
const get = async (verb, unit) => {
|
|
122
137
|
const r = await fetch(`${origin}/__unit/${verb}?unit=${encodeURIComponent(unit)}`, { headers });
|
|
123
138
|
const out = await r.json().catch(() => ({}));
|
|
124
|
-
return r.ok ? out : { status: r.status, ...out };
|
|
139
|
+
return r.ok ? out : { status: r.status, ...explainRefusal(out, r.status) };
|
|
125
140
|
};
|
|
126
141
|
return {
|
|
127
142
|
open: (b) => post("open", b), save: (b) => post("save", b), land: (b) => post("land", b),
|
|
@@ -129,16 +144,31 @@ export function unitClient({ origin, token, space, session }) {
|
|
|
129
144
|
main: (unit) => get("main", unit),
|
|
130
145
|
async blobPut(h, body) {
|
|
131
146
|
const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { method: "PUT", headers, body });
|
|
132
|
-
if (!r.ok && r.status !== 204) throw
|
|
147
|
+
if (!r.ok && r.status !== 204) throw await refusalError("blob upload", r);
|
|
133
148
|
},
|
|
134
149
|
async blobGet(h) {
|
|
135
150
|
const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { headers });
|
|
136
|
-
if (!r.ok) throw
|
|
151
|
+
if (!r.ok) throw await refusalError("blob fetch", r);
|
|
137
152
|
return Buffer.from(await r.arrayBuffer());
|
|
138
153
|
},
|
|
139
154
|
};
|
|
140
155
|
}
|
|
141
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A non-2xx answer from the blob routes, carried as an error that REMEMBERS it was an
|
|
159
|
+
* answer: the status and whatever the server said. `guarded` turns that back into a
|
|
160
|
+
* result, so a role refusal on an upload reads as the refusal it is — before this, a
|
|
161
|
+
* viewer's save came back as "unreachable, nothing is lost", which was neither.
|
|
162
|
+
*/
|
|
163
|
+
async function refusalError(what, r) {
|
|
164
|
+
let body = null;
|
|
165
|
+
try { body = await r.json(); } catch (e) { /* not JSON */ }
|
|
166
|
+
const err = new Error(`${what} failed: ${r.status}${body && body.error ? ` ${body.error}` : ""}`);
|
|
167
|
+
err.status = r.status;
|
|
168
|
+
err.body = body && typeof body === "object" ? explainRefusal(body, r.status) : null;
|
|
169
|
+
return err;
|
|
170
|
+
}
|
|
171
|
+
|
|
142
172
|
// ── the verbs ────────────────────────────────────────────────────────────────
|
|
143
173
|
// `blobPut`/`blobGet` throw on a non-2xx answer, and a raw `fetch` can reject outright
|
|
144
174
|
// (offline, DNS, a dropped connection mid-transfer) — a transient failure anywhere inside
|
|
@@ -150,7 +180,15 @@ export function unitClient({ origin, token, space, session }) {
|
|
|
150
180
|
function guarded(fn) {
|
|
151
181
|
return async (...args) => {
|
|
152
182
|
try { return await fn(...args); }
|
|
153
|
-
catch (err) {
|
|
183
|
+
catch (err) {
|
|
184
|
+
// An answer the server gave (a status) is a refusal and keeps the server's words;
|
|
185
|
+
// anything else — DNS, a dropped connection, a thrown fixture — is the network.
|
|
186
|
+
if (err && err.status) {
|
|
187
|
+
const body = err.body || {};
|
|
188
|
+
return { ok: false, status: err.status, error: body.error || "refused", message: body.message || String(err.message), ...(body.reason ? { reason: body.reason } : {}) };
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, error: "network", message: String((err && err.message) || err) };
|
|
191
|
+
}
|
|
154
192
|
};
|
|
155
193
|
}
|
|
156
194
|
|
|
@@ -226,7 +264,17 @@ async function doLandImpl({ client, dir, note }) {
|
|
|
226
264
|
if (!st) return { ok: false, error: "not-a-draft", dir };
|
|
227
265
|
const saved = await doSave({ client, dir });
|
|
228
266
|
if (!saved.ok) return saved;
|
|
229
|
-
|
|
267
|
+
// Every landing in a space writes the one manifest by compare-and-set, so eight agents
|
|
268
|
+
// landing eight prototypes in the same second contend on it. The server retries a few
|
|
269
|
+
// times and then answers `manifest-contended`; that is a moment, not a refusal — the
|
|
270
|
+
// draft is still open, its lease released — so this side lands again, with a little
|
|
271
|
+
// jitter, before telling anyone. Measured live: 8 parallel landings, 2 contended.
|
|
272
|
+
let r;
|
|
273
|
+
for (let attempt = 0; ; attempt++) {
|
|
274
|
+
r = await client.land({ unit: st.unit, draftId: st.draftId, baseRevision: st.baseRevision, note: note || "" });
|
|
275
|
+
if (r.error !== "manifest-contended" || attempt >= LAND_RETRIES) break;
|
|
276
|
+
await new Promise((res) => setTimeout(res, 150 + Math.random() * 400 * (attempt + 1)));
|
|
277
|
+
}
|
|
230
278
|
if (r.status) return { ok: false, ...r };
|
|
231
279
|
st.landed = true; st.landedRevision = r.revision;
|
|
232
280
|
writeState(dir, st);
|
package/scripts/open.mjs
CHANGED
|
@@ -43,7 +43,8 @@ if (!r.ok) {
|
|
|
43
43
|
if (r.error === "units-not-configured") die("this instance does not serve drafts yet (no unit store bound).");
|
|
44
44
|
if (r.error === "bad-unit" && r.reason === "reserved-folder") die(`${unit} sits under a folder the engine reserves — a prototype lives at <opportunity>/<prototype>.`);
|
|
45
45
|
if (r.error === "bad-unit" && r.reason === "not-a-prototype-folder") die(`${unit} is not a prototype folder — name one as <opportunity>/<prototype>.`);
|
|
46
|
-
die(`could not
|
|
46
|
+
if (r.error === "network") die(`could not reach the instance (${r.message}).`);
|
|
47
|
+
die(`could not open: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}${r.message ? ` — ${r.message}` : ""}`);
|
|
47
48
|
}
|
|
48
49
|
log(r.isNew ? `draft ${r.draftId} on ${unit} — a NEW prototype; ${dir} is empty, write its index.html there` : `draft ${r.draftId} on ${unit} — ${r.files} file(s) in ${dir}`);
|
|
49
50
|
// The agent tool's hooks, installed for this machine the first time a draft is opened
|
package/scripts/read.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// under _read/<unit>/ beside the draft folders; files carry no write bit and the deny hook
|
|
4
4
|
// refuses edits there. `augur close` inside it removes it. See docs/drafts-that-land.md §7.
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { target, buildStamp } from "./lib/store.mjs";
|
|
7
8
|
import { unitClient, doRead, readDirFor, unitPathFor } from "./lib/draft.mjs";
|
|
8
9
|
import { normUnit } from "../src/unit-core.mjs";
|
|
9
10
|
|
|
@@ -19,12 +20,19 @@ if (!unit) die(`"${raw}" is not a prototype path.`);
|
|
|
19
20
|
let origin, token;
|
|
20
21
|
try { ({ origin, token } = target({ needToken: true })); } catch (e) { die(e.message); }
|
|
21
22
|
const dir = path.resolve(opt("--dir") || readDirFor(unit));
|
|
22
|
-
|
|
23
|
+
// The blob routes are per space, so the copy needs the space id exactly as `open` does:
|
|
24
|
+
// from a space.json in this folder, else from what the instance says it serves.
|
|
25
|
+
let space = null;
|
|
26
|
+
try { space = JSON.parse(fs.readFileSync("space.json", "utf8")).id; } catch (e) { /* not in a space folder */ }
|
|
27
|
+
if (!space) { try { space = Object.keys((await buildStamp(origin)).spaces || {})[0] || null; } catch (e) { /* stamp unreachable */ } }
|
|
28
|
+
if (!space) die("could not tell which space this instance serves — run from a folder with space.json, or set AUGUR_ORIGIN.");
|
|
29
|
+
const client = unitClient({ origin, token, space, session: "" });
|
|
23
30
|
const r = await doRead({ client, unit, dir, origin, now: new Date().toISOString() });
|
|
24
31
|
if (!r.ok) {
|
|
25
32
|
if (r.error === "folder-not-empty") die(`${r.dir} is not empty — pick another folder with --dir.`);
|
|
26
33
|
if (r.error === "units-not-configured") die("this instance does not serve drafts yet (no unit store bound).");
|
|
27
|
-
die(`could not
|
|
34
|
+
if (r.error === "network") die(`could not reach the instance (${r.message}).`);
|
|
35
|
+
die(`could not read: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}${r.message ? ` — ${r.message}` : ""}`);
|
|
28
36
|
}
|
|
29
37
|
log(`${r.files} file(s) of ${unit} at revision ${r.revision}, read-only`);
|
|
30
38
|
console.log(dir);
|
package/scripts/save.mjs
CHANGED
|
@@ -15,7 +15,9 @@ const client = unitClient({ origin, token, space: st.space, session: st.session
|
|
|
15
15
|
const r = await doSave({ client, dir });
|
|
16
16
|
if (!r.ok) {
|
|
17
17
|
if (r.error === "stale-draft" || r.error === "stale-draft-revision") die("this draft moved under you (another process saved to it) — run `augur sync`.");
|
|
18
|
-
die(`
|
|
18
|
+
if (r.error === "draft-closed") die(`this draft was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — the folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
|
|
19
|
+
if (r.error === "network") die(`could not reach the instance (${r.message}). Nothing is lost — the next save carries every change since.`);
|
|
20
|
+
die(`save refused: ${r.error || r.status}${r.message ? ` — ${r.message}` : ""}`);
|
|
19
21
|
}
|
|
20
22
|
if (r.changed.length) console.error(`\x1b[35m[save]\x1b[0m ${r.changed.length} file(s) live at ${origin}${st.address}`);
|
|
21
23
|
console.log(`${origin}${st.address}`);
|
package/scripts/sync.mjs
CHANGED
|
@@ -15,7 +15,9 @@ const token = resolveToken(origin);
|
|
|
15
15
|
if (!token) die("no publish token — run `augur connect` once.");
|
|
16
16
|
const client = unitClient({ origin, token, space: st.space, session: st.session });
|
|
17
17
|
const r = await doSync({ client, dir });
|
|
18
|
-
if (!r.ok) die(`
|
|
18
|
+
if (!r.ok && r.error === "draft-closed") die(`this draft was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — the folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
|
|
19
|
+
if (!r.ok && r.error === "network") die(`could not reach the instance (${r.message}). Nothing is lost — run augur sync again.`);
|
|
20
|
+
if (!r.ok) die(`sync refused: ${r.error || r.status}${r.message ? ` — ${r.message}` : ""}`);
|
|
19
21
|
for (const f of r.taken) log(`took theirs ${f}`);
|
|
20
22
|
for (const f of r.merged) log(`merged ${f}`);
|
|
21
23
|
for (const c of r.conflicts) {
|
package/src/_worker.js
CHANGED
|
@@ -4803,6 +4803,9 @@ function publishRefusalBody(refusal) {
|
|
|
4803
4803
|
"viewer-role": "This account can look around but not publish.",
|
|
4804
4804
|
"not-an-admin": "This token was minted for an admin and this account is no longer one. Run `augur login` again for a token scoped to what it may still publish.",
|
|
4805
4805
|
}[refusal];
|
|
4806
|
+
// `unknown-token` and `wrong-space` stay a bare `forbidden` ON PURPOSE: a guessed token
|
|
4807
|
+
// must not learn whether it exists somewhere. The sentence a person needs — pair again —
|
|
4808
|
+
// is said by the CLI, which knows it just used a saved token (scripts/lib/draft.mjs).
|
|
4806
4809
|
return message ? { error: "forbidden", message } : { error: "forbidden" };
|
|
4807
4810
|
}
|
|
4808
4811
|
|
|
@@ -5099,7 +5102,12 @@ function defaultSpaceIdFromManifests(manifests) {
|
|
|
5099
5102
|
* rule the commit handler already keeps. `unitSources` records the landing so the old
|
|
5100
5103
|
* composed publish treats the unit as somebody's work rather than as a fast-forward.
|
|
5101
5104
|
*/
|
|
5102
|
-
|
|
5105
|
+
// Every landing in a space writes the one manifest by compare-and-set. Three attempts
|
|
5106
|
+
// with no pause between them lost 2 of 8 landings made in the same second (measured live,
|
|
5107
|
+
// 6 Sep 2026); six attempts with a little jitter between them is what a burst of agents
|
|
5108
|
+
// landing at once needs. The CLI lands again on `manifest-contended` as well.
|
|
5109
|
+
const UNIT_LANDING_ATTEMPTS = 6;
|
|
5110
|
+
const landingBackoff = (attempt) => new Promise((r) => setTimeout(r, 30 + Math.random() * 90 * (attempt + 1)));
|
|
5103
5111
|
async function writeUnitLanding(tctx, env, spaceId, unit, table, changed, who, now) {
|
|
5104
5112
|
const bundles = bundlesFor(env, tctx.tenantId);
|
|
5105
5113
|
const key = `spaces/${spaceId}/manifest.json`;
|
|
@@ -5184,7 +5192,7 @@ async function writeUnitLanding(tctx, env, spaceId, unit, table, changed, who, n
|
|
|
5184
5192
|
// A store that answers `null` refused the precondition — R2's way of saying the object
|
|
5185
5193
|
// moved under us. Anything else is a write.
|
|
5186
5194
|
const wrote = await bundles.put(key, JSON.stringify(out), etag ? { onlyIf: { etagMatches: etag } } : undefined);
|
|
5187
|
-
if (etag && wrote === null) { bustManifests(tctx.tenantId); continue; }
|
|
5195
|
+
if (etag && wrote === null) { bustManifests(tctx.tenantId); await landingBackoff(attempt); continue; }
|
|
5188
5196
|
// THE BYTES ARE LIVE FROM HERE: the manifest is the pointer visitors follow. The version
|
|
5189
5197
|
// document is the rollback record, and a store failure writing it must not turn a landing
|
|
5190
5198
|
// that already happened into a reported failure with no lease release — it is logged as
|
|
@@ -5275,6 +5283,12 @@ async function unitCaller(tctx, request, env, spaceId) {
|
|
|
5275
5283
|
return { who: { personId: personId(me.email), label: me.email }, session: session || "browser" };
|
|
5276
5284
|
}
|
|
5277
5285
|
|
|
5286
|
+
/** A `draft-closed` answer names who landed it by id; the roster puts the face on it. */
|
|
5287
|
+
function closedFace(tctx, body) {
|
|
5288
|
+
if (!body || body.error !== "draft-closed" || !body.by) return body;
|
|
5289
|
+
return { ...body, ...personFace(tctx.USERS, body.by) };
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5278
5292
|
async function unitApi(tctx, request, url, env) {
|
|
5279
5293
|
const verb = url.pathname.slice(UNIT_API_PREFIX.length);
|
|
5280
5294
|
if (!/^[a-z-]+$/.test(verb)) return jsonResponse({ error: "bad-path" }, 400);
|
|
@@ -5399,13 +5413,13 @@ async function unitApi(tctx, request, url, env) {
|
|
|
5399
5413
|
}
|
|
5400
5414
|
if (missing.length) return jsonResponse({ error: "missing-blobs", missing: [...new Set(missing)] }, 409);
|
|
5401
5415
|
const r = await unitCall(stub, "/save", { draftId: body.draftId, draftRevision: body.draftRevision, changes, baseRevision: body.baseRevision, at: now });
|
|
5402
|
-
return jsonResponse(r.body, r.status);
|
|
5416
|
+
return jsonResponse(closedFace(tctx, r.body), r.status);
|
|
5403
5417
|
}
|
|
5404
5418
|
if (verb === "land" || verb === "restore") {
|
|
5405
5419
|
const r = await unitCall(stub, `/${verb}`, verb === "land"
|
|
5406
5420
|
? { draftId: body.draftId, baseRevision: body.baseRevision, at: now }
|
|
5407
5421
|
: { revision: body.revision, at: now });
|
|
5408
|
-
if (r.status !== 200) return jsonResponse(r.body, r.status);
|
|
5422
|
+
if (r.status !== 200) return jsonResponse(closedFace(tctx, r.body), r.status);
|
|
5409
5423
|
const written = await writeUnitLanding(tctx, env, spaceId, unit, r.body.table, r.body.changed, who, now);
|
|
5410
5424
|
if (written.error) {
|
|
5411
5425
|
await unitCall(stub, "/abandon-land", { lease: r.body.lease });
|
|
@@ -5444,7 +5458,7 @@ async function unitApi(tctx, request, url, env) {
|
|
|
5444
5458
|
if (verb === "sync" || verb === "discard") {
|
|
5445
5459
|
const r = await unitCall(stub, `/${verb}`, { draftId: body.draftId, at: now });
|
|
5446
5460
|
if (verb === "discard" && r.status === 200) await noteUnitDrafts(tctx, env, stub, unit, now);
|
|
5447
|
-
return jsonResponse(r.body, r.status);
|
|
5461
|
+
return jsonResponse(closedFace(tctx, r.body), r.status);
|
|
5448
5462
|
}
|
|
5449
5463
|
return jsonResponse({ error: "unknown-verb" }, 404);
|
|
5450
5464
|
}
|
|
@@ -9119,7 +9133,13 @@ function doorText(f) {
|
|
|
9119
9133
|
+ ` ${f.connect}\n\n`
|
|
9120
9134
|
+ `It prints a link and a code. The owner of this workspace opens the link in a\n`
|
|
9121
9135
|
+ `browser they are already signed in to and enters the code. The token lands on\n`
|
|
9122
|
-
+ `that machine, and every verb below uses it from then on
|
|
9136
|
+
+ `that machine, and every verb below uses it from then on.\n\n`
|
|
9137
|
+
+ `AGENTS: if your person reads your messages only after you finish your turn, run\n`
|
|
9138
|
+
+ `it with --no-wait. It prints the line and exits; send them that line, end your\n`
|
|
9139
|
+
+ `turn, and run the same command again once they say they approved — the token is\n`
|
|
9140
|
+
+ `collected then, for that same code. Do not poll for the approval in one turn:\n`
|
|
9141
|
+
+ `the code lives five minutes and nobody sees it until you stop.\n\n`
|
|
9142
|
+
+ `With no\n`
|
|
9123
9143
|
+ `source tree yet, \`npx @augurworks/augur clone --space ${f.workspace}\` then fetches one (it reads\n`
|
|
9124
9144
|
+ `the origin from the pairing).\n\n`
|
|
9125
9145
|
: `Device pairing is switched off on this workspace. Ask an admin for an invite;\n`
|
package/src/unit-object.mjs
CHANGED
|
@@ -109,6 +109,24 @@ export class UnitObject {
|
|
|
109
109
|
const rows = [...this.sql.exec(`SELECT * FROM drafts WHERE id = ?`, id)];
|
|
110
110
|
return rows.length ? rowDraft(rows[0]) : null;
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* A verb on a draft that no longer exists as a draft. A draft nobody ever opened is
|
|
114
|
+
* `unknown-draft`; one that was CLOSED answers `draft-closed` and says how — landed
|
|
115
|
+
* (with who landed it, from the landing row it produced) or discarded — because the
|
|
116
|
+
* one process that asks is the owner's terminal, which was not told: any member may
|
|
117
|
+
* land or discard any draft from the bar, and the owner's next save is where they
|
|
118
|
+
* find out. A bare 404 there read as "nothing to do"; this reads as what happened.
|
|
119
|
+
*/
|
|
120
|
+
closedAnswer(d) {
|
|
121
|
+
if (!d) return [404, { error: "unknown-draft" }];
|
|
122
|
+
const landed = !d.discarded;
|
|
123
|
+
let by = null, session = null, at = d.closedAt, revision = null;
|
|
124
|
+
if (landed) {
|
|
125
|
+
const rows = [...this.sql.exec(`SELECT by, session, at, revision FROM landings WHERE draft_id = ? ORDER BY revision DESC LIMIT 1`, d.id)];
|
|
126
|
+
if (rows.length) ({ by, session, at, revision } = rows[0]);
|
|
127
|
+
}
|
|
128
|
+
return [410, { error: "draft-closed", draftId: d.id, landed, discarded: !landed, by, session, at, revision }];
|
|
129
|
+
}
|
|
112
130
|
openDrafts() {
|
|
113
131
|
return [...this.sql.exec(`SELECT * FROM drafts WHERE closed_at IS NULL ORDER BY opened_at`)].map(rowDraft);
|
|
114
132
|
}
|
|
@@ -150,7 +168,7 @@ export class UnitObject {
|
|
|
150
168
|
|
|
151
169
|
save({ draftId, draftRevision, changes, baseRevision, at }) {
|
|
152
170
|
const d = this.draft(draftId);
|
|
153
|
-
if (!d || d.closedAt) return
|
|
171
|
+
if (!d || d.closedAt) return this.closedAnswer(d);
|
|
154
172
|
const held = this.lease(Date.parse(at));
|
|
155
173
|
if (held && held.draftId === draftId) return [409, { error: "landing-in-progress" }];
|
|
156
174
|
if (Number(draftRevision) !== d.revision) return [409, { error: "stale-draft-revision", draftRevision: d.revision }];
|
|
@@ -185,7 +203,7 @@ export class UnitObject {
|
|
|
185
203
|
|
|
186
204
|
land({ draftId, baseRevision, at }) {
|
|
187
205
|
const d = this.draft(draftId);
|
|
188
|
-
if (!d || d.closedAt) return
|
|
206
|
+
if (!d || d.closedAt) return this.closedAnswer(d);
|
|
189
207
|
const main = this.mainRevision();
|
|
190
208
|
if (d.baseRevision !== main || Number(baseRevision) !== main) {
|
|
191
209
|
const base = this.landing(d.baseRevision);
|
|
@@ -230,7 +248,7 @@ export class UnitObject {
|
|
|
230
248
|
|
|
231
249
|
sync({ draftId }) {
|
|
232
250
|
const d = this.draft(draftId);
|
|
233
|
-
if (!d || d.closedAt) return
|
|
251
|
+
if (!d || d.closedAt) return this.closedAnswer(d);
|
|
234
252
|
const base = this.landing(d.baseRevision);
|
|
235
253
|
const delta = tableDelta(base ? JSON.parse(base.tbl) : {}, this.mainTable());
|
|
236
254
|
return [200, { mainRevision: this.mainRevision(), baseRevision: d.baseRevision, ...delta }];
|
|
@@ -238,7 +256,7 @@ export class UnitObject {
|
|
|
238
256
|
|
|
239
257
|
discard({ draftId, at }) {
|
|
240
258
|
const d = this.draft(draftId);
|
|
241
|
-
if (!d || d.closedAt) return
|
|
259
|
+
if (!d || d.closedAt) return this.closedAnswer(d);
|
|
242
260
|
this.sql.exec(`UPDATE drafts SET closed_at = ?, discarded = 1 WHERE id = ?`, at, draftId);
|
|
243
261
|
return [200, { closed: true }];
|
|
244
262
|
}
|