@augurworks/augur 0.15.2 → 0.15.3
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/INSTALL.md +3 -2
- package/README.md +4 -2
- package/agents/README.md +4 -5
- package/agents/drafts.md +11 -6
- package/agents/prototype-contract.md +1 -1
- package/agents/publishing.md +23 -16
- package/build.js +1 -97
- package/package.json +1 -1
- package/scripts/cli.mjs +11 -5
- package/scripts/clone.mjs +0 -20
- package/scripts/init.mjs +2 -2
- package/scripts/lib/adapters.mjs +23 -3
- package/scripts/lib/draft.mjs +22 -2
- package/scripts/no-tenant-globals.mjs +16 -0
- package/scripts/open.mjs +8 -6
- package/scripts/publish.mjs +19 -0
- package/scripts/read.mjs +2 -3
- package/scripts/status.mjs +5 -23
- package/src/_worker.js +139 -309
- package/src/galleries.mjs +400 -0
- package/src/state-inventory.mjs +0 -4
- package/agents/working-marks.md +0 -86
- package/scripts/lib/marks.mjs +0 -107
- package/scripts/mark.mjs +0 -112
- package/scripts/ship.mjs +0 -460
package/src/_worker.js
CHANGED
|
@@ -104,6 +104,10 @@ import { PURGED_AUTHOR, purgeThreads, idCollisions } from "./purge.mjs";
|
|
|
104
104
|
import { authoredUnits, unitOfPath, unitPaths } from "./publish-units.mjs";
|
|
105
105
|
import { composePublish, forkLanded } from "./publish-compose.mjs";
|
|
106
106
|
import { normUnit, splitDraftPath, unitTable, draftAddress, DRAFT_ID_RE } from "./unit-core.mjs";
|
|
107
|
+
import {
|
|
108
|
+
siteModel, derivedPathKind, renderRootIndex, renderOpportunityIndex, renderPlaygroundIndex,
|
|
109
|
+
renderTierIndex, renderComponentsIndex, searchIndex, catalogFrom,
|
|
110
|
+
} from "./galleries.mjs";
|
|
107
111
|
// `F-fork-verb`. Fork as a deliberate verb — one unit aliased to a new path, zero bytes
|
|
108
112
|
// moved — plus the rule that keeps a fork's lineage and owner alive across every later
|
|
109
113
|
// publish. Both are pure, and both are the CLI's too if it ever needs them.
|
|
@@ -4333,7 +4337,7 @@ function resolveBundlePath(manifests, pathname) {
|
|
|
4333
4337
|
// against. It is the first argument for the same reason `loadTenantContext` takes it
|
|
4334
4338
|
// first: everything below reads one workspace's store, and a function that had to guess
|
|
4335
4339
|
// would resolve a path against whatever the isolate happened to hold.
|
|
4336
|
-
async function assetFetch(tenantId, env, request) {
|
|
4340
|
+
async function assetFetch(tenantId, env, request, opts = {}) {
|
|
4337
4341
|
if (!bundleMode(env)) return env.ASSETS.fetch(request);
|
|
4338
4342
|
const url = new URL(request.url);
|
|
4339
4343
|
// ── a draft address: the draft's table, not the manifest ─────────────────────────
|
|
@@ -4352,6 +4356,22 @@ async function assetFetch(tenantId, env, request) {
|
|
|
4352
4356
|
return blobResponse(env, request, f);
|
|
4353
4357
|
}
|
|
4354
4358
|
const manifests = await loadManifests(tenantId, env);
|
|
4359
|
+
// ── a design-system draft, across the site (`?ds=<draft>`, §5) ──────────────────
|
|
4360
|
+
// A path under the workspace's declared skill folder resolves from THAT draft's table
|
|
4361
|
+
// when the request carries one, so a shared change can be looked at on every prototype
|
|
4362
|
+
// before it lands. Only the skill's own paths: a prototype's files never come from it,
|
|
4363
|
+
// and a draft that does not hold the file falls through to main.
|
|
4364
|
+
if (opts.dsDraft && DRAFT_ID_RE.test(opts.dsDraft) && decoded) {
|
|
4365
|
+
const skill = Object.values(manifests)
|
|
4366
|
+
.flatMap((m) => ((m && m.routing) || {}).publicSkillPrefixes || [])
|
|
4367
|
+
.map(normUnit).find((p) => p && decoded.startsWith(p));
|
|
4368
|
+
const stub = skill ? unitStub(env, tenantId, skill) : null;
|
|
4369
|
+
if (stub) {
|
|
4370
|
+
const dr = await unitCall(stub, `/draft/${opts.dsDraft}`, null, "GET");
|
|
4371
|
+
const f = dr.status === 200 && dr.body.table ? dr.body.table[decoded] : null;
|
|
4372
|
+
if (f) return blobResponse(env, request, f);
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4355
4375
|
const r = resolveBundlePath(manifests, url.pathname);
|
|
4356
4376
|
if (r.redirect) return Response.redirect(new URL(r.redirect + url.search, url).toString(), 308);
|
|
4357
4377
|
if (r.miss) return new Response("Not Found", { status: 404 });
|
|
@@ -5029,21 +5049,23 @@ const UNIT_CT_RE = /^[\w.+-]+\/[\w.+-]+(; ?charset=[\w-]+)?$/i;
|
|
|
5029
5049
|
// to anonymous visitors, from a draft on a folder nobody edits.
|
|
5030
5050
|
const isNewUnitPath = (unit) => unit.split("/").filter(Boolean).length === 2;
|
|
5031
5051
|
// Top-level folders a NEW unit may not be created under. A landing replaces a unit's
|
|
5032
|
-
// folder wholesale, so
|
|
5033
|
-
//
|
|
5034
|
-
//
|
|
5035
|
-
//
|
|
5036
|
-
//
|
|
5037
|
-
//
|
|
5038
|
-
//
|
|
5052
|
+
// folder wholesale, so the folders whose contents are GENERATED — the tokens page, the
|
|
5053
|
+
// primitives gallery, the changelog, the search index, fonts, engine chrome (`_`, `__`) —
|
|
5054
|
+
// are never a unit. The four library tiers are NOT here: `/components/button/` is one
|
|
5055
|
+
// demo folder, a unit like any prototype, and the tier's own index is a sibling file the
|
|
5056
|
+
// derived renderer owns. `skills` stays reserved with ONE exception, decided where the
|
|
5057
|
+
// workspace's declared design system is known (`unitApi`): the skill folder the space
|
|
5058
|
+
// names is the design-system unit. Stateless and the same on every workspace. A unit the
|
|
5059
|
+
// manifest ALREADY declares is never held to this: the manifest is the authority.
|
|
5039
5060
|
const RESERVED_UNIT_FOLDERS = Object.freeze([
|
|
5040
|
-
"
|
|
5041
|
-
"admin", "changelog", "search",
|
|
5061
|
+
"skills", "tokens", "primitives", "fonts", "tracks", "admin", "changelog", "search",
|
|
5042
5062
|
]);
|
|
5043
5063
|
const isReservedUnitFolder = (unit) => {
|
|
5044
5064
|
const first = unit.split("/").filter(Boolean)[0] || "";
|
|
5045
5065
|
return first.startsWith("_") || RESERVED_UNIT_FOLDERS.includes(first);
|
|
5046
5066
|
};
|
|
5067
|
+
/** The one unit allowed under `skills/`: the design system the workspace declares. */
|
|
5068
|
+
const isDeclaredSkillUnit = (unit, tctx) => ((tctx && tctx.PUBLIC_SKILL_PREFIXES) || []).some((p) => normUnit(p) === unit);
|
|
5047
5069
|
const shortText = (s, n) => String(s == null ? "" : s).slice(0, n);
|
|
5048
5070
|
// `tctx.SPACES` is a routing field, filled from the live manifests by `loadTenantContext`
|
|
5049
5071
|
// on the request's normal path — which this route also goes through, since `handleRequest`
|
|
@@ -5140,7 +5162,11 @@ async function writeUnitLanding(tctx, env, spaceId, unit, table, changed, who, n
|
|
|
5140
5162
|
// A unit the manifest already declares keeps the entry it has, spelling and all: the
|
|
5141
5163
|
// set union that replaced it added a second, normalized entry beside one written
|
|
5142
5164
|
// without its trailing slash, and two entries for one unit is two things to prune.
|
|
5143
|
-
|
|
5165
|
+
// The design-system unit is the one landing that adds NO public prefix: its public
|
|
5166
|
+
// surface is the rendered-asset rule `publicSkillPrefixes` already spells, and a whole
|
|
5167
|
+
// skill folder opened to the gate would expose the documents that rule keeps gated.
|
|
5168
|
+
const skillUnit = ((cur.routing || {}).publicSkillPrefixes || []).some((p) => normUnit(p) === unit);
|
|
5169
|
+
routing.publicPrefixes = authoredUnits(cur).has(unit) || skillUnit
|
|
5144
5170
|
? [...(routing.publicPrefixes || [])]
|
|
5145
5171
|
: [...(routing.publicPrefixes || []), unit];
|
|
5146
5172
|
routing.unitSources = { ...(routing.unitSources || {}), [unit]: { sha: null, dirty: false, landed: true, by: who.personId, at: now } };
|
|
@@ -5297,7 +5323,7 @@ async function unitApi(tctx, request, url, env) {
|
|
|
5297
5323
|
// reached, so a folder that is not one is never given a draft to be adopted from later.
|
|
5298
5324
|
// A unit the space ALREADY publishes is a unit whatever its shape (the manifest is the
|
|
5299
5325
|
// authority on what exists); anything else has to be the shape a new one is created in.
|
|
5300
|
-
if (!authoredUnits(live).has(unit)) {
|
|
5326
|
+
if (!authoredUnits(live).has(unit) && !isDeclaredSkillUnit(unit, tctx)) {
|
|
5301
5327
|
if (!isNewUnitPath(unit)) return jsonResponse({ error: "bad-unit", reason: "not-a-prototype-folder" }, 400);
|
|
5302
5328
|
if (isReservedUnitFolder(unit)) return jsonResponse({ error: "bad-unit", reason: "reserved-folder" }, 400);
|
|
5303
5329
|
}
|
|
@@ -5427,46 +5453,6 @@ async function publishApi(tctx, request, url, env) {
|
|
|
5427
5453
|
const [spaceId, op, arg] = url.pathname.slice("/__publish/".length).split("/");
|
|
5428
5454
|
if (!spaceId || !op || !/^[a-z0-9_][a-z0-9-]*$/.test(spaceId)) return jsonResponse({ error: "bad-path" }, 400);
|
|
5429
5455
|
|
|
5430
|
-
// ── working marks (`F-presence-marks`) ─────────────────────────────────────
|
|
5431
|
-
//
|
|
5432
|
-
// AHEAD OF THE BUNDLE-STORE GUARD, deliberately: a mark is not published content and
|
|
5433
|
-
// holds nothing the store knows about, so an instance serving from ASSETS — `augur dev`,
|
|
5434
|
-
// `npm run offline`, a raw engine build — is exactly where two agents most need to stay
|
|
5435
|
-
// out of each other's way, and 501 there would be an accident of where the check sits.
|
|
5436
|
-
//
|
|
5437
|
-
// ANY VALID PUBLISH TOKEN, whatever its scope. A mark names a path, not a space, and a
|
|
5438
|
-
// space-scoped token holder is precisely the person whose work-start is worth announcing.
|
|
5439
|
-
// The capability gate still applies, so a restricted credential (the control plane's
|
|
5440
|
-
// purge token) reaches this no more than it reaches anything else.
|
|
5441
|
-
if (spaceId === "_marks") {
|
|
5442
|
-
const a = await publishAuthDetailed(tctx, request, env, spaceId, true);
|
|
5443
|
-
if (!a.entry) return jsonResponse(publishRefusalBody(a.refusal), 403);
|
|
5444
|
-
if (capabilityRefusal(a.entry, spaceId, op)) {
|
|
5445
|
-
return jsonResponse({ error: "forbidden", reason: "capability-not-granted" }, 403);
|
|
5446
|
-
}
|
|
5447
|
-
// WHO, from the credential and never from the body. `augur login` labels a token with
|
|
5448
|
-
// the holder's address; a token an admin minted by hand carries whatever they typed,
|
|
5449
|
-
// which hashes to a stable id that resolves to no roster face — honest, and better
|
|
5450
|
-
// than letting the caller name itself.
|
|
5451
|
-
const who = { personId: personId(a.entry.label || "") };
|
|
5452
|
-
if (op === "list" && request.method === "GET") {
|
|
5453
|
-
return jsonResponse({ ...(await readMarks(tctx, env)), ttlMs: MARK_TTL_MS, maxTtlMs: MARK_TTL_MAX_MS });
|
|
5454
|
-
}
|
|
5455
|
-
let body = null;
|
|
5456
|
-
if (request.method === "POST") {
|
|
5457
|
-
try { body = await request.json(); } catch (e) { return jsonResponse({ error: "bad-json" }, 400); }
|
|
5458
|
-
}
|
|
5459
|
-
if (op === "set" && request.method === "POST") {
|
|
5460
|
-
const out = await writeMark(tctx, env, who, { path: body && body.path, ttl: body && body.ttl });
|
|
5461
|
-
return out.error ? jsonResponse(out, out.error === "bad-input" ? 400 : 503) : jsonResponse(out);
|
|
5462
|
-
}
|
|
5463
|
-
if (op === "clear" && request.method === "POST") {
|
|
5464
|
-
const out = await clearMark(tctx, env, who, { path: body && body.path });
|
|
5465
|
-
return out.error ? jsonResponse(out, out.error === "bad-input" ? 400 : 503) : jsonResponse(out);
|
|
5466
|
-
}
|
|
5467
|
-
return jsonResponse({ error: "unknown-op" }, 400);
|
|
5468
|
-
}
|
|
5469
|
-
|
|
5470
5456
|
if (!env.BUNDLES) return jsonResponse({ error: "bundle-store-not-configured" }, 501);
|
|
5471
5457
|
// This workspace's view of the store. `blobs/` is content-addressed and shared, so the
|
|
5472
5458
|
// three `blobs/…` operations below deliberately keep using the binding directly — the
|
|
@@ -7238,6 +7224,94 @@ async function composeChrome(tctx, res, url) {
|
|
|
7238
7224
|
return new Response(html, { status: res.status, statusText: res.statusText, headers });
|
|
7239
7225
|
}
|
|
7240
7226
|
|
|
7227
|
+
// ---- Derived pages (drafts that land, §6.4) -------------------------------------
|
|
7228
|
+
// Where drafts are served, the gallery, each opportunity's index, the playground, the
|
|
7229
|
+
// library tiers and the finder's index are RENDERED HERE from the live store, so a landing
|
|
7230
|
+
// is on them at once and no client build ships them. The stored copies a publish once
|
|
7231
|
+
// baked are ignored on this path — they cannot know about a landing. Where drafts are not
|
|
7232
|
+
// served, nothing here runs and the stored pages serve exactly as before.
|
|
7233
|
+
//
|
|
7234
|
+
// Two store reads feed a render besides the manifest — the status baseline
|
|
7235
|
+
// (`/prototype-status.json`) and the design-system catalog (`/registry.json`) — cached per
|
|
7236
|
+
// manifest version; the status OVERLAY is read fresh every time, one get, as the status
|
|
7237
|
+
// route itself reads it. Per-request work after that is string rendering.
|
|
7238
|
+
const DERIVED = tenantCache("derived");
|
|
7239
|
+
const DERIVED_PATH_RE = /^\/(?:[^/]+\/?(?:index\.html)?|index\.html|__search\.json)?$/;
|
|
7240
|
+
async function derivedInputs(tctx, env, spaceId, manifest) {
|
|
7241
|
+
const slot = DERIVED.entry(tctx.tenantId, () => ({ key: null, baseline: {}, catalog: {} }));
|
|
7242
|
+
const key = `${spaceId}:${manifest.version}`;
|
|
7243
|
+
if (slot.key === key) return slot;
|
|
7244
|
+
const readJson = async (p) => {
|
|
7245
|
+
const f = (manifest.files || {})[p];
|
|
7246
|
+
if (!f || !env.BUNDLES) return null;
|
|
7247
|
+
try {
|
|
7248
|
+
const obj = await env.BUNDLES.get("blobs/" + f.h);
|
|
7249
|
+
return obj ? JSON.parse(await obj.text()) : null;
|
|
7250
|
+
} catch (e) { return null; }
|
|
7251
|
+
};
|
|
7252
|
+
const base = await readJson("/prototype-status.json");
|
|
7253
|
+
const baseline = {};
|
|
7254
|
+
for (const [k, v] of Object.entries(base && typeof base === "object" ? base : {})) {
|
|
7255
|
+
if (!k.startsWith("_") && typeof v === "string") baseline[k] = v;
|
|
7256
|
+
}
|
|
7257
|
+
slot.baseline = baseline;
|
|
7258
|
+
slot.catalog = catalogFrom(await readJson("/registry.json"));
|
|
7259
|
+
slot.key = key;
|
|
7260
|
+
return slot;
|
|
7261
|
+
}
|
|
7262
|
+
/** A derived page for this path, or null when the path is not one (or drafts are not served here). */
|
|
7263
|
+
async function derivedPage(tctx, env, url) {
|
|
7264
|
+
if (!draftsServedHere(env) || !DERIVED_PATH_RE.test(url.pathname)) return null;
|
|
7265
|
+
const manifests = await loadManifests(tctx.tenantId, env);
|
|
7266
|
+
const spaceId = defaultSpaceIdFromCtx(tctx) || defaultSpaceIdFromManifests(manifests);
|
|
7267
|
+
const manifest = spaceId ? manifests[spaceId] : null;
|
|
7268
|
+
if (!manifest || !manifest.files) return null;
|
|
7269
|
+
const inputs = await derivedInputs(tctx, env, spaceId, manifest);
|
|
7270
|
+
const store = overlayFor(env, tctx);
|
|
7271
|
+
let statuses = {};
|
|
7272
|
+
try { statuses = store ? (await store.read("statuses")) || {} : {}; } catch (e) { statuses = {}; }
|
|
7273
|
+
// A recorded author resolves to a face through the roster; an id nobody answers to
|
|
7274
|
+
// (an ex-member, the `live` adoption) is nobody's face rather than a blank chip.
|
|
7275
|
+
const people = (id) => { const f = personFace(tctx.USERS, id); return f.name ? { id, ...f } : null; };
|
|
7276
|
+
const now = Date.now();
|
|
7277
|
+
const model = siteModel({ manifest, statuses, baseline: inputs.baseline, people, now });
|
|
7278
|
+
const kind = derivedPathKind(url.pathname, model);
|
|
7279
|
+
if (!kind) return null;
|
|
7280
|
+
const sp = (tctx.SPACES || []).find((s) => s.id === spaceId) || {};
|
|
7281
|
+
const ctx = { spaces: tctx.SPACES || [], activeSpace: spaceId, chrome: tctx.CHROME_POINTER || null, projectsLabel: sp.projectsLabel || "", now };
|
|
7282
|
+
if (kind.kind === "search") return jsonResponse(searchIndex(model, ctx), 200, { "Cache-Control": "no-cache" });
|
|
7283
|
+
if (kind.kind === "opportunity" && !kind.slash) return Response.redirect(new URL(`/${encodeURIComponent(kind.name)}/${url.search}`, url).toString(), 308);
|
|
7284
|
+
const html = kind.kind === "root" ? renderRootIndex(model, ctx)
|
|
7285
|
+
: kind.kind === "opportunity" ? renderOpportunityIndex(model, kind.name, ctx)
|
|
7286
|
+
: kind.kind === "playground" ? renderPlaygroundIndex(model, ctx)
|
|
7287
|
+
: kind.kind === "tier" ? renderTierIndex(model, kind.tier, ctx)
|
|
7288
|
+
: kind.kind === "components" ? renderComponentsIndex(model, inputs.catalog, ctx)
|
|
7289
|
+
: null;
|
|
7290
|
+
if (html == null) return null;
|
|
7291
|
+
return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": ASSET_REVALIDATE } });
|
|
7292
|
+
}
|
|
7293
|
+
|
|
7294
|
+
// ---- The design-system overlay cookie (`?ds=`) --------------------------------
|
|
7295
|
+
// `?ds=<draft>` on any page names a design-system draft to look through; the cookie keeps
|
|
7296
|
+
// it while the person navigates; `?ds=` with nothing drops it. A malformed id is nobody's
|
|
7297
|
+
// draft and touches no cookie. Members only — the branch that reads this is past the gate.
|
|
7298
|
+
const DS_COOKIE = "augur_ds";
|
|
7299
|
+
function dsOverlay(request, url) {
|
|
7300
|
+
if (url.searchParams.has("ds")) {
|
|
7301
|
+
const v = url.searchParams.get("ds") || "";
|
|
7302
|
+
if (v === "") return { draft: null, setCookie: `${DS_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; Secure; HttpOnly` };
|
|
7303
|
+
if (!DRAFT_ID_RE.test(v)) return { draft: null, setCookie: null };
|
|
7304
|
+
return { draft: v, setCookie: `${DS_COOKIE}=${v}; Path=/; SameSite=Lax; Secure; HttpOnly` };
|
|
7305
|
+
}
|
|
7306
|
+
const c = cookieValue(request.headers.get("Cookie") || "", DS_COOKIE);
|
|
7307
|
+
return { draft: c && DRAFT_ID_RE.test(c) ? c : null, setCookie: null };
|
|
7308
|
+
}
|
|
7309
|
+
function withSetCookie(res, cookie) {
|
|
7310
|
+
const out = new Response(res.body, res);
|
|
7311
|
+
out.headers.append("Set-Cookie", cookie);
|
|
7312
|
+
return out;
|
|
7313
|
+
}
|
|
7314
|
+
|
|
7241
7315
|
// ---- The draft bar (drafts that land, §5) -------------------------------------
|
|
7242
7316
|
// A prototype is raw HTML from a space and carries no chrome; a draft has no build step
|
|
7243
7317
|
// that could bake one in. So a member looking at a unit's page — main or a draft address —
|
|
@@ -7835,16 +7909,6 @@ const OVERLAY_KV_KEYS = Object.freeze({
|
|
|
7835
7909
|
boards: Object.freeze({ doc: "board", layout: "keyed", workspaceScoped: true }),
|
|
7836
7910
|
// Canvas image METADATA — the bytes are in R2. See assetApi.
|
|
7837
7911
|
assets: Object.freeze({ doc: "basset-meta", layout: "keyed" }),
|
|
7838
|
-
// WORKING MARKS — "something is editing here right now". One row per path, and the row
|
|
7839
|
-
// is only meaningful until its own TTL runs out. See the marks section below.
|
|
7840
|
-
//
|
|
7841
|
-
// `map` RATHER THAN `keyed`, and the trade is worth naming because it looks backwards.
|
|
7842
|
-
// Keyed would give one KV document per path, so two marks written in the same window
|
|
7843
|
-
// could not lose each other. It would also turn every READ into a kv.list plus a get per
|
|
7844
|
-
// row — and marks are read by a gallery page as well as by the CLI, which puts a listing
|
|
7845
|
-
// in front of ordinary page loads on a store whose daily get budget has been exhausted
|
|
7846
|
-
// before. One document is one get. What it costs is stated on `writeMark`.
|
|
7847
|
-
marks: Object.freeze({ doc: "marks", layout: "map" }),
|
|
7848
7912
|
// WHICH UNITS HAVE OPEN DRAFTS — a hint the gallery reads so it can ask the right unit
|
|
7849
7913
|
// objects and no others (`draftsIndexApi`). One row per unit, `{n, at}`, written from
|
|
7850
7914
|
// the object's own presence answer after open, land and discard. NEVER THE TRUTH: the
|
|
@@ -9055,11 +9119,9 @@ function doorText(f) {
|
|
|
9055
9119
|
+ ` ${f.connect}\n\n`
|
|
9056
9120
|
+ `It prints a link and a code. The owner of this workspace opens the link in a\n`
|
|
9057
9121
|
+ `browser they are already signed in to and enters the code. The token lands on\n`
|
|
9058
|
-
+ `that machine, and
|
|
9122
|
+
+ `that machine, and every verb below uses it from then on. With no\n`
|
|
9059
9123
|
+ `source tree yet, \`npx @augurworks/augur clone --space ${f.workspace}\` then fetches one (it reads\n`
|
|
9060
9124
|
+ `the origin from the pairing).\n\n`
|
|
9061
|
-
+ `Not on npm yet? The engine clone sits next to every workspace that publishes:\n\n`
|
|
9062
|
-
+ ` node <engine>/scripts/cli.mjs connect --origin ${f.origin}\n\n`
|
|
9063
9125
|
: `Device pairing is switched off on this workspace. Ask an admin for an invite;\n`
|
|
9064
9126
|
+ `once you have signed in, \`augur login\` (email and password, meant for CI)\n`
|
|
9065
9127
|
+ `trades that for a publish token.\n\n`;
|
|
@@ -10221,235 +10283,6 @@ async function nameApi(tctx, request, url, env) {
|
|
|
10221
10283
|
return jsonResponse({ error: "method-not-allowed" }, 405);
|
|
10222
10284
|
}
|
|
10223
10285
|
|
|
10224
|
-
// ---- Working marks (KV-backed, single key) ----------------------------------
|
|
10225
|
-
//
|
|
10226
|
-
// `F-presence-marks`. Nothing anywhere said what was already being worked on. Two
|
|
10227
|
-
// collaborators' tools — usually two agents, on two machines, told to improve "the
|
|
10228
|
-
// checkout flow" — would each open the same folder, each edit it, and find out at publish
|
|
10229
|
-
// time, where the answer is a fork and a conflict file nobody asked for.
|
|
10230
|
-
//
|
|
10231
|
-
// ⚠️ THIS IS DELIBERATELY NOT A LOCK, and every line below is written so it cannot become
|
|
10232
|
-
// one. A mark REFUSES NOTHING. It is not consulted by the gate, by the publish handler, by
|
|
10233
|
-
// the commit CAS or by anything else that could say no. It is a note left where the next
|
|
10234
|
-
// reader will look, and the whole protocol is: write one before you start, read them
|
|
10235
|
-
// before you start. Enforcement when coordination fails is the composed publish's job
|
|
10236
|
-
// (`src/publish-compose.mjs`), which is the only place in this engine allowed to refuse a
|
|
10237
|
-
// write over a collision — and it does it on evidence, after the fact, never on a claim.
|
|
10238
|
-
//
|
|
10239
|
-
// THE PROTOCOL IS AGENT-FIRST. The badge a person sees on a gallery card is the byproduct,
|
|
10240
|
-
// not the point: as an agent's edit shrinks toward seconds, a mark is FELT almost never and
|
|
10241
|
-
// READ always. So the write side is the CLI (`augur mark`, over a publish token, see the
|
|
10242
|
-
// `_marks` branch in publishApi) and the browser side is read-only — a person editing in a
|
|
10243
|
-
// tab is not running a work-start step and inventing one for them would be a lie about who
|
|
10244
|
-
// wrote what.
|
|
10245
|
-
//
|
|
10246
|
-
// ⚠️ A MARK EXPIRES BY ITSELF AND IS NEVER TRUSTED TO BE CLEARED. The thing that leaves a
|
|
10247
|
-
// mark is a process that can be killed — Ctrl-C, an OOM, a laptop lid — and a claim that
|
|
10248
|
-
// outlives the claimant is worse than no claim at all, because the next reader believes it.
|
|
10249
|
-
// So EXPIRY IS A READ-TIME FILTER (`liveMarks`), not a cleanup job: the moment `startedAt +
|
|
10250
|
-
// ttl` is in the past the mark is gone from every answer, whether or not anything ever runs
|
|
10251
|
-
// again. `sweepExpired` below only reclaims the BYTES, opportunistically, and correctness
|
|
10252
|
-
// never depends on it having run.
|
|
10253
|
-
|
|
10254
|
-
/** How long a mark is good for when the caller does not say. */
|
|
10255
|
-
const MARK_TTL_MS = 10 * 60_000;
|
|
10256
|
-
/**
|
|
10257
|
-
* The longest a caller may ask for. An agent that wants four hours is describing a lock,
|
|
10258
|
-
* and the answer to a lock is a shorter mark re-written as the work continues.
|
|
10259
|
-
*/
|
|
10260
|
-
const MARK_TTL_MAX_MS = 60 * 60_000;
|
|
10261
|
-
/** The shortest, so a `--ttl 0` cannot write a mark that is already dead. */
|
|
10262
|
-
const MARK_TTL_MIN_MS = 5_000;
|
|
10263
|
-
/**
|
|
10264
|
-
* How many lapsed rows one write may reclaim. Bounded low because on the KV backing each
|
|
10265
|
-
* row delete is a whole-document read and put, so the sweep can cost more than the litter
|
|
10266
|
-
* it collects; an unbounded one would turn a work-start step into a hundred writes on a
|
|
10267
|
-
* workspace nobody has marked in a month. Nothing depends on it running at all.
|
|
10268
|
-
*/
|
|
10269
|
-
const MARK_SWEEP_MAX = 4;
|
|
10270
|
-
/** Belt and braces: a workspace cannot be filled with marks by a loop. */
|
|
10271
|
-
const MARK_MAX_ROWS = 200;
|
|
10272
|
-
|
|
10273
|
-
/**
|
|
10274
|
-
* One spelling of a path, so two tools that mean the same folder agree.
|
|
10275
|
-
*
|
|
10276
|
-
* Leading and trailing slash, always: a mark names a UNIT — the prototype folder a URL
|
|
10277
|
-
* names and a person edits — and `unitOfPath` in src/publish-units.mjs decides containment
|
|
10278
|
-
* by prefix, which only works when a folder ends in a slash. `/a/b` and `/a/bc/` would
|
|
10279
|
-
* otherwise overlap.
|
|
10280
|
-
*/
|
|
10281
|
-
function normalizeMarkPath(p) {
|
|
10282
|
-
const s = clamp(p, 300);
|
|
10283
|
-
if (!s) return "";
|
|
10284
|
-
const trimmed = s.trim().replace(/^\.\//, "").replace(/\/{2,}/g, "/");
|
|
10285
|
-
if (!trimmed || trimmed === "/") return "/";
|
|
10286
|
-
return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
|
|
10287
|
-
}
|
|
10288
|
-
|
|
10289
|
-
/**
|
|
10290
|
-
* Do these two paths describe overlapping work? Containment in either direction — a mark
|
|
10291
|
-
* on `/checkout/` covers `/checkout/step-two/`, and a mark on `/checkout/step-two/` is
|
|
10292
|
-
* worth showing to somebody about to take `/checkout/`.
|
|
10293
|
-
*/
|
|
10294
|
-
function markPathsOverlap(a, b) {
|
|
10295
|
-
const x = normalizeMarkPath(a), y = normalizeMarkPath(b);
|
|
10296
|
-
if (!x || !y) return false;
|
|
10297
|
-
return x === y || x.startsWith(y) || y.startsWith(x);
|
|
10298
|
-
}
|
|
10299
|
-
|
|
10300
|
-
/** The instant a mark stops meaning anything. Pure, and the only definition of expiry. */
|
|
10301
|
-
function markExpiresAt(m) {
|
|
10302
|
-
const started = Date.parse((m && m.startedAt) || "");
|
|
10303
|
-
if (!Number.isFinite(started)) return 0;
|
|
10304
|
-
const ttl = Number.isFinite(+(m && m.ttl)) ? +m.ttl : MARK_TTL_MS;
|
|
10305
|
-
return started + Math.min(Math.max(ttl, MARK_TTL_MIN_MS), MARK_TTL_MAX_MS);
|
|
10306
|
-
}
|
|
10307
|
-
|
|
10308
|
-
/**
|
|
10309
|
-
* The live marks in a stored map, newest first. THE expiry rule — every reader goes
|
|
10310
|
-
* through here, so a lapsed mark cannot be reported by one surface and hidden by another.
|
|
10311
|
-
*/
|
|
10312
|
-
function liveMarks(map, now = Date.now()) {
|
|
10313
|
-
return Object.entries(map || {})
|
|
10314
|
-
.map(([path, m]) => (m && typeof m === "object" ? { ...m, path: m.path || path } : null))
|
|
10315
|
-
.filter((m) => m && markExpiresAt(m) > now)
|
|
10316
|
-
.sort((a, b) => Date.parse(b.startedAt || 0) - Date.parse(a.startedAt || 0));
|
|
10317
|
-
}
|
|
10318
|
-
|
|
10319
|
-
/**
|
|
10320
|
-
* What a mark looks like on the wire: the stored row plus two things a reader would
|
|
10321
|
-
* otherwise have to compute, and one it could not — the display name behind the id.
|
|
10322
|
-
*
|
|
10323
|
-
* The NAME IS RESOLVED, NEVER STORED. `personId` is the same one-way hash a comment
|
|
10324
|
-
* carries, so a mark holds no address; the roster turns it back into a face at read time,
|
|
10325
|
-
* which also means a rename shows through and an ex-member resolves to nobody.
|
|
10326
|
-
*/
|
|
10327
|
-
function decorateMark(m, users, now = Date.now()) {
|
|
10328
|
-
const u = (users || []).find((x) => x && personId(x.email) === m.personId);
|
|
10329
|
-
return {
|
|
10330
|
-
path: m.path,
|
|
10331
|
-
personId: m.personId || null,
|
|
10332
|
-
startedAt: m.startedAt,
|
|
10333
|
-
ttl: markExpiresAt(m) - Date.parse(m.startedAt),
|
|
10334
|
-
by: u ? u.name || nameFromEmail(u.email) : null,
|
|
10335
|
-
initials: u ? u.initials || initialsFor(u.name || u.email) : null,
|
|
10336
|
-
color: u ? u.color || null : null,
|
|
10337
|
-
expiresIn: Math.max(0, markExpiresAt(m) - now),
|
|
10338
|
-
};
|
|
10339
|
-
}
|
|
10340
|
-
|
|
10341
|
-
/** Read the live marks for a workspace, decorated. `null` store answers with nothing. */
|
|
10342
|
-
async function readMarks(tctx, env) {
|
|
10343
|
-
const store = overlayFor(env, tctx);
|
|
10344
|
-
if (!store) return { marks: [], warning: "no-kv-binding" };
|
|
10345
|
-
const map = await store.read("marks");
|
|
10346
|
-
const now = Date.now();
|
|
10347
|
-
return { marks: liveMarks(map, now).map((m) => decorateMark(m, tctx.USERS, now)), now };
|
|
10348
|
-
}
|
|
10349
|
-
|
|
10350
|
-
/**
|
|
10351
|
-
* Reclaim the bytes of rows that lapsed. NOT the expiry mechanism — `liveMarks` already
|
|
10352
|
-
* stopped reporting these, and this runs only so a one-way author id does not sit in the
|
|
10353
|
-
* store for months after it stopped meaning anything. Per-key deletes, never a whole-family
|
|
10354
|
-
* `replace`: a replace computed from a read taken moments ago would drop a mark another
|
|
10355
|
-
* agent wrote in between, and on the workspace object it would delete rows it never read.
|
|
10356
|
-
*
|
|
10357
|
-
* It swallows its own failures on purpose. Reclaiming bytes may never be the reason a
|
|
10358
|
-
* work-start step reports a failure, because the mark it was announcing is already written.
|
|
10359
|
-
*/
|
|
10360
|
-
async function sweepExpired(store, map, now, keep) {
|
|
10361
|
-
let swept = 0;
|
|
10362
|
-
for (const [path, m] of Object.entries(map || {})) {
|
|
10363
|
-
if (swept >= MARK_SWEEP_MAX) break;
|
|
10364
|
-
if (path === keep) continue;
|
|
10365
|
-
if (markExpiresAt(m) > now) continue;
|
|
10366
|
-
try { await store.set("marks", "", path, null); swept++; } catch (e) { break; }
|
|
10367
|
-
}
|
|
10368
|
-
return swept;
|
|
10369
|
-
}
|
|
10370
|
-
|
|
10371
|
-
/**
|
|
10372
|
-
* Write one mark. `who` is resolved by the caller from a credential — a session or a
|
|
10373
|
-
* publish token — and NEVER from the request body, exactly like a comment's authorship.
|
|
10374
|
-
*
|
|
10375
|
-
* ⚠️ ON THE KV BACKING TWO MARKS WRITTEN IN THE SAME WINDOW CAN LOSE EACH OTHER, and that
|
|
10376
|
-
* is a known cost rather than an oversight. A `map` family is one document: `set` reads it,
|
|
10377
|
-
* changes one key and puts it back, so a mark written between the read and the put is
|
|
10378
|
-
* overwritten — and KV reads converge globally rather than instantly, which makes the
|
|
10379
|
-
* window as wide as the convergence, not as wide as the round trip. The overlay's own
|
|
10380
|
-
* header says the same thing about statuses, names and pins; the workspace object closes
|
|
10381
|
-
* it for all of them at once by making each key a row.
|
|
10382
|
-
*
|
|
10383
|
-
* WHY IT IS SURVIVABLE HERE AND WOULD NOT BE IN A LOCK. A lost mark costs the next reader
|
|
10384
|
-
* a hint. It cannot cost anybody work, because nothing anywhere asks a mark for permission:
|
|
10385
|
-
* the loser of the race is still editing, still publishing, and still protected by the
|
|
10386
|
-
* composed publish, which settles a real collision on evidence. A lock that lost a write
|
|
10387
|
-
* would hand two writers the same exclusive claim, which is why this is not one.
|
|
10388
|
-
*/
|
|
10389
|
-
async function writeMark(tctx, env, who, { path, ttl }) {
|
|
10390
|
-
const store = overlayFor(env, tctx);
|
|
10391
|
-
if (!store) return { error: "no-kv-binding" };
|
|
10392
|
-
const p = normalizeMarkPath(path);
|
|
10393
|
-
if (!p) return { error: "bad-input" };
|
|
10394
|
-
const ms = Number.isFinite(+ttl) && +ttl > 0
|
|
10395
|
-
? Math.min(Math.max(+ttl, MARK_TTL_MIN_MS), MARK_TTL_MAX_MS)
|
|
10396
|
-
: MARK_TTL_MS;
|
|
10397
|
-
const now = Date.now();
|
|
10398
|
-
const before = await store.read("marks");
|
|
10399
|
-
// The only refusal on this route, and it is a runaway-loop guard rather than a policy:
|
|
10400
|
-
// this many things being worked on at once in one workspace is a script, not a team.
|
|
10401
|
-
const live = liveMarks(before, now);
|
|
10402
|
-
if (live.length >= MARK_MAX_ROWS && !live.some((m) => m.path === p)) {
|
|
10403
|
-
return { error: "too-many-marks" };
|
|
10404
|
-
}
|
|
10405
|
-
// ⚠️ THE ROW CARRIES NO ADDRESS — not in the value and not in the `owner` column. A mark
|
|
10406
|
-
// is read by more things than a comment thread is (a gallery page stamps a badge from
|
|
10407
|
-
// it), and `personId` is exactly enough to put a face on it.
|
|
10408
|
-
const mark = { path: p, personId: who.personId, startedAt: new Date(now).toISOString(), ttl: ms };
|
|
10409
|
-
await store.set("marks", "", p, mark, null);
|
|
10410
|
-
const swept = await sweepExpired(store, before, now, p);
|
|
10411
|
-
// The answer is COMPUTED from what was just written, never re-read. A second read costs
|
|
10412
|
-
// a round trip to say what this function already knows, and on KV it can come back
|
|
10413
|
-
// STALER than the write it was meant to confirm — a work-start step that printed "your
|
|
10414
|
-
// mark is not there" right after writing it would teach people to distrust the tool.
|
|
10415
|
-
const marks = [mark, ...live.filter((m) => m.path !== p)];
|
|
10416
|
-
return {
|
|
10417
|
-
mark: decorateMark(mark, tctx.USERS, now),
|
|
10418
|
-
marks: marks.map((m) => decorateMark(m, tctx.USERS, now)),
|
|
10419
|
-
swept,
|
|
10420
|
-
};
|
|
10421
|
-
}
|
|
10422
|
-
|
|
10423
|
-
/**
|
|
10424
|
-
* Release a mark early. A COURTESY, never the guarantee — the TTL is the guarantee, and a
|
|
10425
|
-
* tool that is killed never reaches this. Only the mark's own author may clear it: taking
|
|
10426
|
-
* somebody else's mark down would turn the note into something worth fighting over.
|
|
10427
|
-
*/
|
|
10428
|
-
async function clearMark(tctx, env, who, { path }) {
|
|
10429
|
-
const store = overlayFor(env, tctx);
|
|
10430
|
-
if (!store) return { error: "no-kv-binding" };
|
|
10431
|
-
const p = normalizeMarkPath(path);
|
|
10432
|
-
if (!p) return { error: "bad-input" };
|
|
10433
|
-
const map = await store.read("marks");
|
|
10434
|
-
const cur = (map || {})[p];
|
|
10435
|
-
const now = Date.now();
|
|
10436
|
-
if (!cur || markExpiresAt(cur) <= now) return { cleared: false, reason: "no-mark" };
|
|
10437
|
-
if (cur.personId !== who.personId) return { cleared: false, reason: "not-yours" };
|
|
10438
|
-
await store.set("marks", "", p, null);
|
|
10439
|
-
const marks = liveMarks(map, now).filter((m) => m.path !== p);
|
|
10440
|
-
return { cleared: true, marks: marks.map((m) => decorateMark(m, tctx.USERS, now)) };
|
|
10441
|
-
}
|
|
10442
|
-
|
|
10443
|
-
/**
|
|
10444
|
-
* The browser's read. GET only, on purpose — see the header: the badge is the byproduct of
|
|
10445
|
-
* an agent protocol, and a tab is not a work-start step.
|
|
10446
|
-
*/
|
|
10447
|
-
async function marksApi(tctx, request, url, env) {
|
|
10448
|
-
if (request.method !== "GET") return jsonResponse({ error: "method-not-allowed" }, 405);
|
|
10449
|
-
const out = await readMarks(tctx, env);
|
|
10450
|
-
return jsonResponse({ ...out, ttlMs: MARK_TTL_MS });
|
|
10451
|
-
}
|
|
10452
|
-
|
|
10453
10286
|
// ---- Prototype deletion (repo-write via dispatch webhook) -------------------
|
|
10454
10287
|
// "Delete forever" on a prototype card. The worker holds NO repo credentials —
|
|
10455
10288
|
// it forwards the request to a per-instance webhook (a GitHub repository_dispatch
|
|
@@ -12299,13 +12132,6 @@ async function handleRequest(request, env, ctx, url, trace) {
|
|
|
12299
12132
|
if (!authed) return jsonResponse({ error: "unauthorized" }, 401);
|
|
12300
12133
|
return pinsApi(tctx, request, url, env, me);
|
|
12301
12134
|
}
|
|
12302
|
-
// Working marks — READ ONLY here. Who is working where is workspace-internal, so it
|
|
12303
|
-
// asks for a session like the gallery around it; the WRITE side is a publish token and
|
|
12304
|
-
// lives under /__publish/_marks, because a work-start step is something a tool runs.
|
|
12305
|
-
if (url.pathname === "/__marks") {
|
|
12306
|
-
if (!authed) return jsonResponse({ error: "unauthorized" }, 401);
|
|
12307
|
-
return marksApi(tctx, request, url, env);
|
|
12308
|
-
}
|
|
12309
12135
|
if (url.pathname === "/__name") {
|
|
12310
12136
|
if (!authed) return jsonResponse({ error: "unauthorized" }, 401);
|
|
12311
12137
|
const denied = viewerWriteRefusal(request, url, me, "name", tctx.SPACES);
|
|
@@ -12433,13 +12259,20 @@ async function handleRequest(request, env, ctx, url, trace) {
|
|
|
12433
12259
|
// Past the gate (or nothing gates the site) → serve. A 404 gets one more chance
|
|
12434
12260
|
// as a created canvas (a KV-registered board with no repo file — see canvasesApi).
|
|
12435
12261
|
if (authed) {
|
|
12436
|
-
|
|
12262
|
+
// A design-system draft to look through (`?ds=`), remembered in a cookie.
|
|
12263
|
+
const ds = dsOverlay(request, url);
|
|
12264
|
+
const stamp = (res) => (ds.setCookie ? withSetCookie(res, ds.setCookie) : res);
|
|
12265
|
+
// Where drafts are served, the gallery and its indexes are derived from the live
|
|
12266
|
+
// store rather than read from it — a landing is on them at once.
|
|
12267
|
+
const derived = await derivedPage(tctx, env, url);
|
|
12268
|
+
if (derived) return stamp(derived.status === 308 ? derived : await serveContent(tctx, derived, url, me, env));
|
|
12269
|
+
const asset = await assetFetch(tctx.tenantId, env, request, { dsDraft: ds.draft });
|
|
12437
12270
|
if (asset.status === 404) {
|
|
12438
12271
|
const virt = await virtualCanvas(tctx, request, env, url);
|
|
12439
|
-
if (virt) return virt;
|
|
12440
|
-
return notFoundResponse(tctx);
|
|
12272
|
+
if (virt) return stamp(virt);
|
|
12273
|
+
return stamp(notFoundResponse(tctx));
|
|
12441
12274
|
}
|
|
12442
|
-
return serveContent(tctx, asset, url, me, env);
|
|
12275
|
+
return stamp(await serveContent(tctx, asset, url, me, env));
|
|
12443
12276
|
}
|
|
12444
12277
|
|
|
12445
12278
|
// Created canvas boards are public like published prototypes — same obscure
|
|
@@ -12504,9 +12337,6 @@ export const __testables = Object.freeze({
|
|
|
12504
12337
|
nextPublishVersion, overlayFor, overlayKvKey, statusApi, nameApi, pinsApi,
|
|
12505
12338
|
currencyApi, currencyRows, freshness, whenWords, parseSince, unitKey, unitProvenance,
|
|
12506
12339
|
STALE_AFTER_DAYS, STATUS_LABELS, VALID_STATUS,
|
|
12507
|
-
marksApi, readMarks, writeMark, clearMark, liveMarks, markExpiresAt, decorateMark,
|
|
12508
|
-
normalizeMarkPath, markPathsOverlap, sweepExpired,
|
|
12509
|
-
MARK_TTL_MS, MARK_TTL_MAX_MS, MARK_TTL_MIN_MS, MARK_SWEEP_MAX, MARK_MAX_ROWS,
|
|
12510
12340
|
exportState, importState,
|
|
12511
12341
|
quotaBump, quotaMinute, quotaDay, workspaceStatus, touchWorkspaceActivity,
|
|
12512
12342
|
deleteWorkspace, purgeDue, blobGc, clearFamilies, NEVER_CLEARED,
|
|
@@ -12524,7 +12354,7 @@ export const __testables = Object.freeze({
|
|
|
12524
12354
|
doorFacts, doorText, wantsMachineDoor, gateResponse, DOOR_DOCS, DOOR_WELL_KNOWN,
|
|
12525
12355
|
resumeAfterDormancy,
|
|
12526
12356
|
PITI_VIEW_KEY, PITI_REMARKS_KEY,
|
|
12527
|
-
publishAuthDetailed, unitApi, unitCaller, personFace, withDraftUi, draftUiBoot, isEngineChrome, publishRefusalBody, splitDraftPath,
|
|
12357
|
+
publishAuthDetailed, unitApi, unitCaller, personFace, withDraftUi, draftUiBoot, derivedPage, dsOverlay, isEngineChrome, publishRefusalBody, splitDraftPath,
|
|
12528
12358
|
adminStorageApi,
|
|
12529
12359
|
adminCustomDomainApi,
|
|
12530
12360
|
isPrefixBacked, backedPublicPrefixes,
|