@augurworks/augur 0.15.3 → 0.15.4

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 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. The owner opens the link in a browser they are already
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 |
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augurworks/augur",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "files": [
5
5
  "scripts/",
6
6
  "build.js",
@@ -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
- const start = await post("start");
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,15 @@ 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. Talking to your person through messages`);
116
+ console.log(` rather than a terminal? Use \`augur connect --no-wait\`.${C.off}`);
117
+ console.log("");
71
118
 
72
119
  const deadline = Date.now() + (expiresInMs || 300000);
73
120
  let saved = null;
@@ -76,9 +123,13 @@ while (Date.now() < deadline) {
76
123
  const res = await post("claim", { code, deviceSecret });
77
124
  if (res.status === 200 && res.json && res.json.token) { saved = res.json; break; }
78
125
  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.");
126
+ if (res.status === 404) { writePending(null); die("this pairing is no longer valid. Run `augur connect` again."); }
80
127
  }
81
- if (!saved) die(`nobody approved it within ${mins} minutes. Run \`augur connect\` again.`);
128
+ if (!saved) { writePending(null); die(`nobody approved it within ${mins} minutes. Run \`augur connect\` again.`); }
129
+ writePending(null);
130
+ finish(saved);
131
+
132
+ function finish(saved) {
82
133
 
83
134
  const dir = path.join(os.homedir(), ".config", "augur");
84
135
  mkdirSync(dir, { recursive: true });
@@ -100,4 +151,6 @@ if (saved.expiresAt) {
100
151
  console.log(`${C.dim}It expires in ${days} days (${saved.expiresAt.slice(0, 10)}). Run \`augur connect\` again then.${C.off}`);
101
152
  } else {
102
153
  console.log(`${C.dim}It expires on its own. Run \`augur connect\` again when it does.${C.off}`);
154
+ }
155
+ process.exit(0);
103
156
  }
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
- die(`land refused: ${r.error || r.status}`);
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
@@ -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 new Error(`blob upload failed: ${r.status}`);
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 new Error(`blob fetch failed: ${r.status}`);
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) { return { ok: false, error: "network", message: String((err && err.message) || 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
- const r = await client.land({ unit: st.unit, draftId: st.draftId, baseRevision: st.baseRevision, note: note || "" });
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 open: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}`);
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 { target } from "./lib/store.mjs";
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
- const client = unitClient({ origin, token, space: "", session: "" });
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 read: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}`);
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(`save refused: ${r.error || r.status}`);
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(`sync refused: ${r.error || r.status}`);
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
- const UNIT_LANDING_ATTEMPTS = 3;
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,10 @@ 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. With no\n`
9136
+ + `that machine, and every verb below uses it from then on. Talking to that person\n`
9137
+ + `through messages rather than a terminal? Add --no-wait: it prints the line and\n`
9138
+ + `exits; run the same command again after they approve and the token is collected.\n`
9139
+ + `With no\n`
9123
9140
  + `source tree yet, \`npx @augurworks/augur clone --space ${f.workspace}\` then fetches one (it reads\n`
9124
9141
  + `the origin from the pairing).\n\n`
9125
9142
  : `Device pairing is switched off on this workspace. Ask an admin for an invite;\n`
@@ -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 [404, { error: "unknown-draft" }];
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 [404, { error: "unknown-draft" }];
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 [404, { error: "unknown-draft" }];
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 [404, { error: "unknown-draft" }];
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
  }