venture backends on Cloudflare: a domain, a Worker, a database and AI per venture
With CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, each venture gets its own Worker (time-<slug>) with its own D1 database and Workers AI. The Worker serves the venture's GitHub Pages folder under <slug>.<TIME_VENTURE_DOMAIN> (or workers.dev) and adds a small API: share links stored in D1, and a rate-limited small model. The page reaches it through time.js, which Time writes next to index.html and the build may not edit; every call degrades to null, so products still work on Pages alone and in the headless checks. The backend is deployed when a venture is picked, so the plan, the signal issue and the page all carry the venture's own domain. A Cloudflare failure never holds a venture: it launches on Pages and the chores retry. The chores also backfill older ventures and redeploy every venture when worker.js or its settings change (a revision hash), a couple per tick; update.yml carries the new files. The doctor checks the token. Offline tests cover the provisioner, the loop with and without Cloudflare, the backfill and redeploys, and the worker itself (node, against a fake D1, AI and Pages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
This commit is contained in:
1 parent
e1dc4f2790
commit
134a03ce41
13 files changed
+717
-11
No files matched your search
@@ -0,0 +1,177 @@
|
||||
"""Cloudflare backing for ventures: each venture gets a domain, a Worker, a D1 database and Workers AI.
|
||||
|
||||
python3 .github/time/cloudflare.py check # what the token can do (doctor)
|
||||
python3 .github/time/cloudflare.py deploy <slug> <pages-folder-url>
|
||||
|
||||
Off unless CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set. The page stays on GitHub Pages; the Worker
|
||||
(cloudflare/worker.js, one script `time-<slug>` per venture) serves it under the venture's own domain and adds the API
|
||||
the page's time.js client calls: share links stored in D1, and a rate-limited small model. With TIME_VENTURE_DOMAIN set
|
||||
(a zone on the same Cloudflare account) a venture lives at <slug>.<domain>; without it, at the script's workers.dev URL.
|
||||
|
||||
Everything here is idempotent: deploying again updates the script in place and reuses the database and the domain.
|
||||
The deployed revision (REV) is a hash of the worker code and its settings, so the venture loop can redeploy every
|
||||
venture when Time's update brings a new worker, and leave them alone otherwise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
API = "https://api.cloudflare.com/client/v4"
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WORKER = HERE / "cloudflare" / "worker.js"
|
||||
CLIENT = HERE / "cloudflare" / "time.js"
|
||||
COMPAT = "2026-09-01"
|
||||
AI_MODEL = "@cf/meta/llama-3.1-8b-instruct"
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
return (os.environ.get(name) or default).strip()
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(env("CLOUDFLARE_API_TOKEN") and env("CLOUDFLARE_ACCOUNT_ID"))
|
||||
|
||||
|
||||
def account() -> str:
|
||||
return f"/accounts/{env('CLOUDFLARE_ACCOUNT_ID')}"
|
||||
|
||||
|
||||
def cf(method: str, path: str, body=None, *, data: bytes | None = None, ctype: str = "application/json"):
|
||||
"""One Cloudflare API call; returns `result`. Errors name the call and Cloudflare's own messages, never the token."""
|
||||
raw = data if data is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
req = urllib.request.Request(API + path, method=method, data=raw,
|
||||
headers={"Authorization": f"Bearer {env('CLOUDFLARE_API_TOKEN')}", "Content-Type": ctype})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
out = json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
text = e.read().decode(errors="replace")
|
||||
try:
|
||||
msgs = "; ".join(f"{x.get('code')} {x.get('message')}" for x in json.loads(text).get("errors", []))
|
||||
except ValueError:
|
||||
msgs = text[:200]
|
||||
raise RuntimeError(f"cloudflare {method} {path.split('?')[0].replace(env('CLOUDFLARE_ACCOUNT_ID'), '<account>')} "
|
||||
f"-> {e.code}: {msgs}") from None
|
||||
return out.get("result")
|
||||
|
||||
|
||||
def script_name(slug: str) -> str:
|
||||
return re.sub(r"[^a-z0-9-]", "-", f"time-{slug}".lower())[:63].strip("-")
|
||||
|
||||
|
||||
def host(slug: str) -> str | None:
|
||||
zone = env("TIME_VENTURE_DOMAIN").lower().strip(".")
|
||||
return f"{slug}.{zone}" if zone else None
|
||||
|
||||
|
||||
def settings(slug: str, origin: str, pages_origin: str) -> dict:
|
||||
return {"SLUG": slug, "ORIGIN": origin.rstrip("/"), "PAGES_ORIGIN": pages_origin,
|
||||
"AI_MODEL": env("TIME_VENTURE_AI_MODEL", AI_MODEL), "AI_DAILY": env("TIME_VENTURE_AI_DAILY", "200"),
|
||||
"SAVE_DAILY": env("TIME_VENTURE_SAVE_DAILY", "2000")}
|
||||
|
||||
|
||||
def rev(vars_: dict) -> str:
|
||||
"""The revision a deploy would make: worker code + settings + domain. Equal revs mean nothing to do."""
|
||||
h = hashlib.sha256(WORKER.read_bytes())
|
||||
h.update(json.dumps({**vars_, "host": host(vars_["SLUG"]), "compat": COMPAT}, sort_keys=True).encode())
|
||||
return h.hexdigest()[:12]
|
||||
|
||||
|
||||
def database(slug: str) -> str:
|
||||
"""The venture's own D1 database (created once). Returns its id."""
|
||||
name = script_name(slug)
|
||||
for d in cf("GET", f"{account()}/d1/database?name={name}") or []:
|
||||
if d.get("name") == name:
|
||||
return d["uuid"]
|
||||
return cf("POST", f"{account()}/d1/database", {"name": name})["uuid"]
|
||||
|
||||
|
||||
def multipart(parts: list[tuple[str, str, bytes]]) -> tuple[bytes, str]:
|
||||
b = uuid.uuid4().hex
|
||||
out = b"".join(f'--{b}\r\nContent-Disposition: form-data; name="{n}"; filename="{n}"\r\nContent-Type: {t}\r\n\r\n'.encode()
|
||||
+ data + b"\r\n" for n, t, data in parts) + f"--{b}--\r\n".encode()
|
||||
return out, f"multipart/form-data; boundary={b}"
|
||||
|
||||
|
||||
def upload(name: str, db: str, vars_: dict) -> None:
|
||||
meta = {"main_module": "worker.js", "compatibility_date": COMPAT,
|
||||
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
|
||||
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
|
||||
data, ctype = multipart([("metadata", "application/json", json.dumps(meta).encode()),
|
||||
("worker.js", "application/javascript+module", WORKER.read_bytes())])
|
||||
cf("PUT", f"{account()}/workers/scripts/{name}", data=data, ctype=ctype)
|
||||
|
||||
|
||||
def attach(name: str, hostname: str) -> str:
|
||||
"""Point <slug>.<domain> at the script as a Workers custom domain (Cloudflare adds the DNS record and the cert)."""
|
||||
zone = hostname.split(".", 1)[1]
|
||||
zones = cf("GET", f"/zones?name={zone}") or []
|
||||
if not zones:
|
||||
raise RuntimeError(f"TIME_VENTURE_DOMAIN {zone} is not a zone this Cloudflare token can see")
|
||||
cf("PUT", f"{account()}/workers/domains",
|
||||
{"hostname": hostname, "service": name, "zone_id": zones[0]["id"], "environment": "production"})
|
||||
return f"https://{hostname}/"
|
||||
|
||||
|
||||
def workers_dev(name: str) -> str:
|
||||
cf("POST", f"{account()}/workers/scripts/{name}/subdomain", {"enabled": True, "previews_enabled": False})
|
||||
sub = (cf("GET", f"{account()}/workers/subdomain") or {}).get("subdomain")
|
||||
if not sub:
|
||||
raise RuntimeError("this Cloudflare account has no workers.dev subdomain yet: set TIME_VENTURE_DOMAIN instead")
|
||||
return f"https://{name}.{sub}.workers.dev/"
|
||||
|
||||
|
||||
def pages_origin_of(url: str) -> str:
|
||||
m = re.match(r"(https?://[^/]+)", url)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def deploy(slug: str, origin: str) -> dict:
|
||||
"""Database, script and address for one venture. Returns {url, worker, db, rev, api}."""
|
||||
vars_ = settings(slug, origin, pages_origin_of(origin))
|
||||
vars_["REV"] = r = rev(vars_)
|
||||
name = script_name(slug)
|
||||
db = database(slug)
|
||||
upload(name, db, vars_)
|
||||
hostname = host(slug)
|
||||
url = attach(name, hostname) if hostname else workers_dev(name)
|
||||
return {"url": url, "worker": name, "db": db, "rev": r, "api": url + "api/"}
|
||||
|
||||
|
||||
def client(api: str) -> str:
|
||||
"""The time.js a venture page loads, pointed at its backend."""
|
||||
return CLIENT.read_text().replace("__TIME_API__", api)
|
||||
|
||||
|
||||
def check() -> tuple[bool, str]:
|
||||
"""Can this token do what a venture needs? For the doctor: (ok, detail), with nothing secret in the detail."""
|
||||
if not enabled():
|
||||
return False, "not set (optional): CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID give ventures domains, a database and AI"
|
||||
try:
|
||||
cf("GET", f"{account()}/workers/scripts")
|
||||
cf("GET", f"{account()}/d1/database?per_page=1")
|
||||
zone = env("TIME_VENTURE_DOMAIN")
|
||||
if zone and not cf("GET", f"/zones?name={zone}"):
|
||||
return False, f"works, but TIME_VENTURE_DOMAIN {zone} is not a zone this token can see"
|
||||
return True, f"works: Workers and D1; ventures live at {'<slug>.' + zone if zone else 'workers.dev'}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"set but failing: {str(e)[:160]}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["check"]:
|
||||
ok, detail = check()
|
||||
print(detail)
|
||||
sys.exit(0 if ok else 1)
|
||||
if sys.argv[1:2] == ["deploy"] and len(sys.argv) == 4:
|
||||
print(json.dumps(deploy(sys.argv[2], sys.argv[3]), indent=1))
|
||||
sys.exit(0)
|
||||
sys.exit(__doc__)
|
||||
@@ -0,0 +1,107 @@
|
||||
// Offline test for worker.js: a fake D1 (just the statements the worker uses), fake Workers AI and a fake Pages origin.
|
||||
// Run: node .github/time/cloudflare/test_worker.mjs (test_time.py runs it when node is around)
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const worker = (await import(join(here, "worker.js"))).default;
|
||||
|
||||
class FakeD1 {
|
||||
constructor() { this.items = new Map(); this.quota = new Map(); this.created = 0; }
|
||||
prepare(sql) {
|
||||
const db = this;
|
||||
const stmt = {
|
||||
args: [],
|
||||
bind(...a) { return { ...stmt, args: a }; },
|
||||
async run() { return this.exec(); },
|
||||
async first() { return (await this.exec()).results[0] ?? null; },
|
||||
async exec() {
|
||||
const a = this.args;
|
||||
if (sql.startsWith("CREATE TABLE")) { db.created++; return { results: [] }; }
|
||||
if (sql.startsWith("INSERT INTO quota")) {
|
||||
const n = (db.quota.get(a[0]) || 0) + 1; db.quota.set(a[0], n); return { results: [{ n }] };
|
||||
}
|
||||
if (sql.startsWith("INSERT INTO items")) { db.items.set(a[0], a[1]); return { results: [] }; }
|
||||
if (sql.startsWith("SELECT data FROM items")) { return { results: db.items.has(a[0]) ? [{ data: db.items.get(a[0]) }] : [] }; }
|
||||
throw new Error("unexpected sql " + sql);
|
||||
},
|
||||
};
|
||||
return stmt;
|
||||
}
|
||||
async batch(stmts) { return Promise.all(stmts.map((s) => s.exec())); }
|
||||
}
|
||||
|
||||
const prompts = [];
|
||||
const env = {
|
||||
DB: new FakeD1(), SLUG: "mdecks", REV: "abc123", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io",
|
||||
AI_MODEL: "@cf/test/model", AI_DAILY: "3", SAVE_DAILY: "100",
|
||||
AI: { async run(model, input) { prompts.push({ model, input }); return { response: "a crisp title" }; } },
|
||||
};
|
||||
const pages = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
pages.push({ url, init });
|
||||
if (url.endsWith("/ventures/mdecks/shots")) return new Response(null, { status: 301, headers: { location: "https://o.github.io/r/ventures/mdecks/shots/" } });
|
||||
return new Response(`<html>${url}</html>`, { status: 200, headers: { "content-type": "text/html" } });
|
||||
};
|
||||
const call = (path, init = {}, ip = "1.1.1.1") =>
|
||||
worker.fetch(new Request("https://mdecks.yolo.lol" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
|
||||
const post = (path, body, ip, headers = {}) =>
|
||||
call(path, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json", ...headers } }, ip);
|
||||
|
||||
// health, without touching the database
|
||||
let r = await call("/api/health");
|
||||
assert.deepEqual(await r.json(), { ok: true, slug: "mdecks", rev: "abc123", ai: true });
|
||||
assert.equal(env.DB.created, 0);
|
||||
|
||||
// save and load a creation; the link comes back to this domain
|
||||
r = await post("/api/items", { slides: ["# Hi"] });
|
||||
assert.equal(r.status, 201);
|
||||
const saved = await r.json();
|
||||
assert.match(saved.id, /^[a-f0-9]{12}$/);
|
||||
assert.equal(saved.link, `https://mdecks.yolo.lol/?s=${saved.id}`);
|
||||
assert.equal(env.DB.created, 2);
|
||||
r = await call(`/api/items/${saved.id}`);
|
||||
assert.deepEqual(await r.json(), { slides: ["# Hi"] });
|
||||
assert.equal((await call("/api/items/doesnotexist1")).status, 404);
|
||||
assert.equal((await post("/api/items", "not json")).status, 400);
|
||||
assert.equal((await post("/api/items", JSON.stringify({ x: "y".repeat(70000) }))).status, 413);
|
||||
|
||||
// AI through Workers AI with the configured model; per-person and daily limits
|
||||
r = await post("/api/ai", { prompt: "title for my deck", system: "be brief" }, "2.2.2.2");
|
||||
assert.deepEqual(await r.json(), { text: "a crisp title" });
|
||||
assert.equal(prompts[0].model, "@cf/test/model");
|
||||
assert.deepEqual(prompts[0].input.messages.map((m) => m.role), ["system", "user"]);
|
||||
assert.equal((await post("/api/ai", { prompt: "" }, "2.2.2.2")).status, 400);
|
||||
assert.equal((await post("/api/ai", { prompt: "two" }, "3.3.3.3")).status, 200);
|
||||
assert.equal((await post("/api/ai", { prompt: "three" }, "4.4.4.4")).status, 200);
|
||||
assert.equal((await post("/api/ai", { prompt: "over the day" }, "5.5.5.5")).status, 429);
|
||||
assert.equal(prompts.length, 3);
|
||||
|
||||
// CORS: this domain and the Pages origin, nobody else
|
||||
r = await call("/api/health", { headers: { origin: "https://o.github.io" } });
|
||||
assert.equal(r.headers.get("access-control-allow-origin"), "https://o.github.io");
|
||||
r = await call("/api/health", { headers: { origin: "https://evil.example" } });
|
||||
assert.equal(r.headers.get("access-control-allow-origin"), null);
|
||||
assert.equal((await call("/api/nope")).status, 404);
|
||||
|
||||
// a broken database answers 500 with JSON, not an exception
|
||||
const broken = { ...env, DB: { prepare() { throw new Error("D1 down"); }, batch() { return Promise.reject(new Error("D1 down")); } } };
|
||||
r = await worker.fetch(new Request("https://x.yolo.lol/api/items", { method: "POST", body: "{}" }), broken);
|
||||
assert.equal(r.status, 500);
|
||||
assert.match((await r.json()).detail, /D1 down/);
|
||||
|
||||
// the page: Pages under this domain, query kept, redirects kept on this host
|
||||
r = await call("/?s=" + saved.id);
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/?s=" + saved.id);
|
||||
assert.equal(r.headers.get("x-time-venture"), "mdecks");
|
||||
assert.match(await r.text(), /ventures\/mdecks\//);
|
||||
r = await call("/app.js");
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/app.js");
|
||||
r = await call("");
|
||||
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/");
|
||||
r = await call("/shots");
|
||||
assert.equal(r.status, 301);
|
||||
assert.equal(r.headers.get("location"), "/shots/");
|
||||
|
||||
console.log("WORKER OK");
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it.
|
||||
Every call resolves to null when the backend can't help (no network, over the daily limit, or the page is being
|
||||
checked locally), so a product uses these only for extras and works fully without them.
|
||||
Time.save(obj) -> a share link (string) or null
|
||||
Time.load() -> the object a share link (?s=...) carried, or null
|
||||
Time.ai(prompt, {system}) -> text from a small model, or null
|
||||
Time.ready -> a promise of true when the backend answers */
|
||||
(function () {
|
||||
var API = "__TIME_API__";
|
||||
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
|
||||
|
||||
function call(path, init) {
|
||||
if (local) return Promise.resolve(null);
|
||||
return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
}
|
||||
function post(path, body) {
|
||||
return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
window.Time = {
|
||||
api: API,
|
||||
ready: call("health").then(function (r) { return !!(r && r.ok); }),
|
||||
save: function (obj) {
|
||||
return post("items", obj).then(function (r) { return r && r.link ? r.link : null; });
|
||||
},
|
||||
load: function () {
|
||||
var id = new URLSearchParams(location.search).get("s");
|
||||
return id && /^[a-z0-9]{6,32}$/.test(id) ? call("items/" + id) : Promise.resolve(null);
|
||||
},
|
||||
ai: function (prompt, opts) {
|
||||
return post("ai", { prompt: String(prompt || ""), system: opts && opts.system })
|
||||
.then(function (r) { return r && typeof r.text === "string" ? r.text : null; });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
// A venture's backend on Cloudflare: its own domain in front of its GitHub Pages folder, plus a small API.
|
||||
// Time deploys one copy per venture (script time-<slug>) with its own D1 database; see .github/time/cloudflare.py.
|
||||
//
|
||||
// GET /api/health -> { ok, slug, rev, ai }
|
||||
// POST /api/items -> { id, link } save a JSON creation (<= 64 KB), for share links
|
||||
// GET /api/items/:id -> the saved JSON
|
||||
// POST /api/ai -> { text } { prompt, system? } through Workers AI, rate limited
|
||||
// anything else -> the venture's page on GitHub Pages (ORIGIN), served under this domain
|
||||
//
|
||||
// Bindings: DB (D1), AI (Workers AI). Vars: SLUG, ORIGIN, PAGES_ORIGIN, REV, AI_MODEL, AI_DAILY, SAVE_DAILY.
|
||||
|
||||
const SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
|
||||
];
|
||||
const MAX_ITEM = 65536;
|
||||
const MAX_PROMPT = 4000;
|
||||
let ready = null;
|
||||
|
||||
const json = (body, status = 200, headers = {}) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
|
||||
|
||||
function cors(req, env) {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return {};
|
||||
const self = new URL(req.url).origin;
|
||||
const ok = origin === self || (env.PAGES_ORIGIN && origin === env.PAGES_ORIGIN);
|
||||
return ok ? { "access-control-allow-origin": origin, "access-control-allow-methods": "GET, POST",
|
||||
"access-control-allow-headers": "content-type", "vary": "origin" } : {};
|
||||
}
|
||||
|
||||
async function setup(env) {
|
||||
ready ??= env.DB.batch(SCHEMA.map((s) => env.DB.prepare(s))).catch((e) => { ready = null; throw e; });
|
||||
return ready;
|
||||
}
|
||||
|
||||
async function who(req, env) {
|
||||
const ip = req.headers.get("cf-connecting-ip") || "?";
|
||||
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${env.SLUG}:${ip}`));
|
||||
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
|
||||
async function spend(env, kind, person, perPerson, total) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
|
||||
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
|
||||
return mine.results[0].n <= perPerson && all.results[0].n <= total;
|
||||
}
|
||||
|
||||
async function api(req, env, url, h) {
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: h });
|
||||
if (path === "/api/health") return json({ ok: true, slug: env.SLUG, rev: env.REV, ai: !!env.AI }, 200, h);
|
||||
await setup(env);
|
||||
if (path === "/api/items" && req.method === "POST") {
|
||||
const text = await req.text();
|
||||
if (text.length > MAX_ITEM) return json({ error: "too big (64 KB max)" }, 413, h);
|
||||
try { JSON.parse(text); } catch { return json({ error: "send JSON" }, 400, h); }
|
||||
if (!(await spend(env, "save", await who(req, env), 50, Number(env.SAVE_DAILY || 2000))))
|
||||
return json({ error: "over today's limit" }, 429, h);
|
||||
const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
||||
await env.DB.prepare("INSERT INTO items (id, data, created) VALUES (?, ?, ?)").bind(id, text, new Date().toISOString()).run();
|
||||
return json({ id, link: `${url.origin}/?s=${id}` }, 201, h);
|
||||
}
|
||||
const item = path.match(/^\/api\/items\/([a-z0-9]{6,32})$/);
|
||||
if (item && req.method === "GET") {
|
||||
const row = await env.DB.prepare("SELECT data FROM items WHERE id = ?").bind(item[1]).first();
|
||||
return row ? new Response(row.data, { headers: { "content-type": "application/json; charset=utf-8", ...h } })
|
||||
: json({ error: "not found" }, 404, h);
|
||||
}
|
||||
if (path === "/api/ai" && req.method === "POST") {
|
||||
if (!env.AI) return json({ error: "no AI on this venture" }, 501, h);
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); }
|
||||
const prompt = String(body.prompt || "").slice(0, MAX_PROMPT);
|
||||
if (!prompt) return json({ error: "prompt is empty" }, 400, h);
|
||||
if (!(await spend(env, "ai", await who(req, env), 20, Number(env.AI_DAILY || 200))))
|
||||
return json({ error: "over today's limit" }, 429, h);
|
||||
const messages = [...(body.system ? [{ role: "system", content: String(body.system).slice(0, MAX_PROMPT) }] : []),
|
||||
{ role: "user", content: prompt }];
|
||||
const out = await env.AI.run(env.AI_MODEL, { messages, max_tokens: 512 });
|
||||
return json({ text: out.response ?? "" }, 200, h);
|
||||
}
|
||||
return json({ error: "no such endpoint" }, 404, h);
|
||||
}
|
||||
|
||||
// The page itself: the venture's folder on GitHub Pages, under this domain. Pages redirects folder URLs without a
|
||||
// trailing slash to its own host; those are rewritten to stay here.
|
||||
async function page(req, env, url) {
|
||||
const origin = env.ORIGIN.replace(/\/+$/, "");
|
||||
const up = await fetch(origin + url.pathname + url.search, { redirect: "manual", cf: { cacheTtl: 60, cacheEverything: true } });
|
||||
const headers = new Headers(up.headers);
|
||||
const loc = headers.get("location");
|
||||
if (loc && loc.startsWith(origin)) headers.set("location", loc.slice(origin.length) || "/");
|
||||
headers.set("x-time-venture", env.SLUG);
|
||||
return new Response(up.body, { status: up.status, headers });
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req, env) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
||||
const h = cors(req, env);
|
||||
try { return await api(req, env, url, h); }
|
||||
catch (e) { return json({ error: "backend error", detail: String(e).slice(0, 200) }, 500, h); }
|
||||
}
|
||||
return page(req, env, url);
|
||||
},
|
||||
};
|
||||
@@ -856,3 +856,180 @@ os.environ.pop("TIME_VENTURE_START", None); os.environ.pop("TIME_VENTURE_HEAVY_S
|
||||
c37 = next(i["number"] for i in W.issues.values() if i["title"].endswith("(2)"))
|
||||
assert W.beats(c37)[:3] == ["scouted", "pitched", "picked"], W.beats(c37)
|
||||
print("ALL OK 29")
|
||||
|
||||
# 38 Cloudflare backing: a venture gets its own Worker, database and domain; its page gets time.js; nothing is held up
|
||||
cfb = vt.cfb
|
||||
|
||||
|
||||
class FakeCF:
|
||||
"""Fake Cloudflare API: D1 databases, scripts (with their metadata), custom domains, zones."""
|
||||
def __init__(self, fail=None):
|
||||
self.dbs, self.scripts, self.domains, self.fail, self.calls = {}, {}, {}, fail, []
|
||||
|
||||
def __call__(self, method, path, body=None, *, data=None, ctype="application/json"):
|
||||
self.calls.append((method, path.split("?")[0]))
|
||||
if self.fail and self.fail in path:
|
||||
raise RuntimeError(f"cloudflare {method} {path} -> 403: 10000 Authentication error")
|
||||
if path.startswith("/zones?name="):
|
||||
return [{"id": "z1", "name": path.split("=")[1]}] if path.endswith("yolo.lol") else []
|
||||
if "/d1/database" in path:
|
||||
if method == "POST":
|
||||
self.dbs[body["name"]] = f"uuid-{len(self.dbs) + 1}"
|
||||
return {"uuid": self.dbs[body["name"]], "name": body["name"]}
|
||||
name = dict(urllib.parse.parse_qsl(path.partition("?")[2])).get("name")
|
||||
return [{"name": n, "uuid": u} for n, u in self.dbs.items() if not name or n == name]
|
||||
if path.endswith("/workers/domains"):
|
||||
self.domains[body["hostname"]] = body["service"]; return {}
|
||||
if "/workers/scripts/" in path and method == "PUT":
|
||||
assert ctype.startswith("multipart/form-data; boundary=")
|
||||
meta = json.loads(re.search(rb'name="metadata".*?\r\n\r\n(.*?)\r\n--', data, re.S).group(1))
|
||||
assert b"export default" in data and meta["main_module"] == "worker.js"
|
||||
self.scripts[path.rsplit("/", 1)[1]] = meta; return {}
|
||||
if path.endswith("/subdomain") and method == "POST":
|
||||
return {}
|
||||
if path.endswith("/workers/subdomain"):
|
||||
return {"subdomain": "acct"}
|
||||
if path.endswith("/workers/scripts"):
|
||||
return []
|
||||
raise AssertionError(f"unexpected cloudflare {method} {path}")
|
||||
|
||||
|
||||
def with_cf(fake, extra=None):
|
||||
cfb.cf = fake
|
||||
os.environ.update({"CLOUDFLARE_API_TOKEN": "cft", "CLOUDFLARE_ACCOUNT_ID": "acct", **(extra or {})})
|
||||
|
||||
|
||||
def without_cf():
|
||||
for k in ("CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "TIME_VENTURE_DOMAIN"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
# deploy: one database, one script with its bindings, a custom domain; deploying again reuses them
|
||||
CF = FakeCF()
|
||||
with_cf(CF, {"TIME_VENTURE_DOMAIN": "yolo.lol"})
|
||||
be = cfb.deploy("mdecks", "https://o.github.io/r/ventures/mdecks/")
|
||||
assert be["url"] == "https://mdecks.yolo.lol/" and be["api"] == "https://mdecks.yolo.lol/api/" and be["worker"] == "time-mdecks", be
|
||||
meta = CF.scripts["time-mdecks"]
|
||||
kinds = {b["name"]: b for b in meta["bindings"]}
|
||||
assert kinds["DB"] == {"type": "d1", "name": "DB", "id": "uuid-1"} and kinds["AI"]["type"] == "ai", kinds
|
||||
assert kinds["ORIGIN"]["text"] == "https://o.github.io/r/ventures/mdecks" and kinds["PAGES_ORIGIN"]["text"] == "https://o.github.io"
|
||||
assert kinds["REV"]["text"] == be["rev"] and CF.domains == {"mdecks.yolo.lol": "time-mdecks"}
|
||||
assert cfb.deploy("mdecks", "https://o.github.io/r/ventures/mdecks/")["rev"] == be["rev"] and len(CF.dbs) == 1
|
||||
assert "cft" not in str(CF.calls) and "https://mdecks.yolo.lol/api/" in cfb.client(be["api"]) and "__TIME_API__" not in cfb.client(be["api"])
|
||||
# no domain: the script's workers.dev address; an unknown zone is a clear error
|
||||
os.environ.pop("TIME_VENTURE_DOMAIN")
|
||||
assert cfb.deploy("dl", "https://o.github.io/r/ventures/dl/")["url"] == "https://time-dl.acct.workers.dev/"
|
||||
os.environ["TIME_VENTURE_DOMAIN"] = "nope.dev"
|
||||
try:
|
||||
cfb.deploy("dl", "https://o.github.io/r/ventures/dl/"); raise AssertionError("expected an error")
|
||||
except RuntimeError as e:
|
||||
assert "nope.dev is not a zone" in str(e)
|
||||
assert cfb.check()[0] is False and "nope.dev" in cfb.check()[1]
|
||||
os.environ["TIME_VENTURE_DOMAIN"] = "yolo.lol"
|
||||
assert cfb.check() == (True, "works: Workers and D1; ventures live at <slug>.yolo.lol")
|
||||
without_cf()
|
||||
assert cfb.check()[0] is False and "optional" in cfb.check()[1]
|
||||
print("ALL OK 30")
|
||||
|
||||
# a new venture with Cloudflare: its own domain everywhere, time.js on Pages, the brief explains the backend
|
||||
W = VWorld()
|
||||
vt._build_on_pages = Worker()
|
||||
CF = FakeCF()
|
||||
with_cf(CF, {"TIME_VENTURE_DOMAIN": "yolo.lol"})
|
||||
vtick({"TIME_VENTURE": "on"})
|
||||
c38 = next(i["number"] for i in W.issues.values() if "venture" in i["labels"])
|
||||
assert W.beats(c38)[-1] == "launched", W.beats(c38)
|
||||
w38 = tm.beats_of(W.comments[c38])[-1]["winner"]
|
||||
assert w38["url"] == "https://mdecks.yolo.lol/" and w38["pages"] == "https://o.github.io/r/ventures/mdecks/", w38
|
||||
assert w38["backend"]["worker"] == "time-mdecks" and "Backend: https://mdecks.yolo.lol/" in W.text(c38)
|
||||
assert "https://mdecks.yolo.lol/api/" in W.files["ventures/mdecks/time.js"]
|
||||
brief = vt._build_on_pages.briefs[0]
|
||||
assert "This venture has a backend at https://mdecks.yolo.lol/" in brief and "Time.save(obj)" in brief and "never write it" in brief
|
||||
reg38 = json.loads(W.files["ventures/ventures.json"])["ventures"][0]
|
||||
assert reg38["url"] == "https://mdecks.yolo.lol/" and reg38["pages"] == w38["pages"] and reg38["backend"]["rev"] == w38["backend"]["rev"]
|
||||
assert "https://mdecks.yolo.lol/" in W.files["ventures/index.html"] and "mdecks.yolo.lol" in W.files["ventures/mdecks/LAUNCH.md"]
|
||||
deploys = len(CF.scripts) and sum(1 for m, p in CF.calls if m == "PUT" and "/workers/scripts/" in p)
|
||||
vtick({"TIME_VENTURE": "on"}) # nothing changed: no redeploy
|
||||
assert sum(1 for m, p in CF.calls if m == "PUT" and "/workers/scripts/" in p) == deploys
|
||||
print("ALL OK 31")
|
||||
|
||||
# Cloudflare refusing doesn't hold the venture: it launches on Pages, and the chores give it a backend once it works
|
||||
W = VWorld()
|
||||
vt._build_on_pages = Worker()
|
||||
with_cf(FakeCF(fail="/d1/database"), {"TIME_VENTURE_DOMAIN": "yolo.lol"})
|
||||
vtick({"TIME_VENTURE": "on"})
|
||||
c39 = next(i["number"] for i in W.issues.values() if "venture" in i["labels"])
|
||||
w39 = tm.beats_of(W.comments[c39])[-1]["winner"]
|
||||
assert W.beats(c39)[-1] == "launched" and w39["url"] == "https://o.github.io/r/ventures/mdecks/" and "backend" not in w39
|
||||
assert "Cloudflare didn't take it" in W.text(c39) and "ventures/mdecks/time.js" not in W.files
|
||||
assert "Backend" not in vt._build_on_pages.briefs[0]
|
||||
CF = FakeCF()
|
||||
with_cf(CF, {"TIME_VENTURE_DOMAIN": "yolo.lol"})
|
||||
vt.refresh_backends(vt.registry())
|
||||
reg39 = json.loads(W.files["ventures/ventures.json"])["ventures"][0]
|
||||
assert reg39["url"] == "https://mdecks.yolo.lol/" and reg39["pages"] == "https://o.github.io/r/ventures/mdecks/", reg39
|
||||
assert "ventures/mdecks/time.js" in W.files and "https://mdecks.yolo.lol/" in W.files["ventures/index.html"]
|
||||
print("ALL OK 32")
|
||||
|
||||
# the chores: old ventures get backfilled (a couple per tick), a new worker.js redeploys everyone, killed ones are left
|
||||
W = VWorld()
|
||||
W.files["ventures/ventures.json"] = json.dumps({"ventures": [
|
||||
{"slug": s, "name": s, "status": st, "url": f"https://o.github.io/r/ventures/{s}/"}
|
||||
for s, st in (("a", "live"), ("b", "parked"), ("c", "live"), ("k", "killed"))]})
|
||||
vt.tm.http = W.http
|
||||
CF = FakeCF()
|
||||
with_cf(CF, {"TIME_VENTURE_DOMAIN": "yolo.lol"})
|
||||
vt.refresh_backends(vt.registry())
|
||||
got = {v["slug"]: v for v in json.loads(W.files["ventures/ventures.json"])["ventures"]}
|
||||
assert sorted(CF.scripts) == ["time-a", "time-b"] and got["c"]["url"].startswith("https://o.github.io"), sorted(CF.scripts)
|
||||
vt.refresh_backends(vt.registry())
|
||||
got = {v["slug"]: v for v in json.loads(W.files["ventures/ventures.json"])["ventures"]}
|
||||
assert sorted(CF.scripts) == ["time-a", "time-b", "time-c"] and "backend" not in got["k"] and got["c"]["url"] == "https://c.yolo.lol/"
|
||||
puts = lambda: sum(1 for m, p in CF.calls if m == "PUT" and "/workers/scripts/" in p)
|
||||
before = puts()
|
||||
vt.refresh_backends(vt.registry())
|
||||
assert puts() == before # up to date: nothing to do
|
||||
real = cfb.WORKER
|
||||
newer = Path(_tf.mkdtemp()) / "worker.js"
|
||||
newer.write_text(real.read_text() + "\n// a newer worker from Time's update\n")
|
||||
cfb.WORKER = newer
|
||||
vt.refresh_backends(vt.registry()); vt.refresh_backends(vt.registry())
|
||||
cfb.WORKER = real
|
||||
assert puts() == before + 3, puts() - before
|
||||
without_cf()
|
||||
print("ALL OK 33")
|
||||
|
||||
# the builds never write time.js, and Time's client doesn't count as the product's code
|
||||
calls = []
|
||||
class FakeHands:
|
||||
def __init__(self, n, branch): self.steps = 0
|
||||
def call(self, name, a): calls.append(a.get("path")); return "wrote"
|
||||
real_hands, real_work = tm.Hands, tm.venice_work
|
||||
tm.Hands = FakeHands
|
||||
tm.venice_work = lambda card, done, spec: {"refusals": [tm.Hands(1, "x").call("write_file", {"path": p, "content": "x"})
|
||||
for p in ("ventures/s/time.js", "ventures/s/./time.js", "ventures/s/app.js")]}
|
||||
# vt._build_on_pages is a scripted Worker here: load a fresh venture.py for the real one
|
||||
vspec2 = importlib.util.spec_from_file_location("vt2", Path(__file__).resolve().parent / "venture.py")
|
||||
vt2 = importlib.util.module_from_spec(vspec2); vspec2.loader.exec_module(vt2)
|
||||
vt2.tm.Hands, vt2.tm.venice_work = tm.Hands, tm.venice_work
|
||||
r = vt2._build_on_pages({"number": 1}, "done", "venice:qwen", root="ventures/s/")
|
||||
tm.Hands, tm.venice_work = real_hands, real_work
|
||||
assert r["refusals"][0].startswith("refused: time.js") and r["refusals"][1].startswith("refused: time.js"), r
|
||||
assert r["refusals"][2] == "wrote" and calls == ["ventures/s/app.js"], (r, calls)
|
||||
W = VWorld()
|
||||
vt.tm.http = W.http
|
||||
wq = {"slug": "q", "signal": 3, "join": "https://github.com/o/r/issues/new?template=early-access.yml&venture=q", "rides": []}
|
||||
W.files["ventures/q/index.html"] = ('<!doctype html><html><head><meta name="viewport" content="width=device-width"></head><body>'
|
||||
+ '<script src="time.js"></script>' + "<!-- x -->" * 400 + "</body></html>")
|
||||
W.files["ventures/q/time.js"] = cfb.client("https://q.yolo.lol/api/") * 3
|
||||
assert any("no working product" in p for p in vt.check_page(wq, {"pricing": []}))
|
||||
print("ALL OK 34")
|
||||
|
||||
# the worker itself (worker.js), against a fake D1, Workers AI and Pages
|
||||
import shutil as _sh, subprocess as _sp # noqa: E402
|
||||
if _sh.which("node"):
|
||||
res = _sp.run(["node", str(Path(__file__).resolve().parent / "cloudflare" / "test_worker.mjs")], capture_output=True, text=True, timeout=60)
|
||||
assert res.returncode == 0 and "WORKER OK" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:]
|
||||
print("ALL OK 35")
|
||||
else:
|
||||
print("skipped the worker test: no node")
|
||||
@@ -10,6 +10,7 @@ workflows; the heartbeat has a daily cap.
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -758,6 +759,11 @@ def cmd_doctor() -> int:
|
||||
row("(optional) VENICE_API_KEY (seats)", ids is not None, f"works, {len(ids)} models" if ids else "not set")
|
||||
except Exception as e: # noqa: BLE001
|
||||
row("(optional) VENICE_API_KEY (seats)", False, f"set but failing: {str(e)[:120]}")
|
||||
cfs = importlib.util.spec_from_file_location("cfb", Path(__file__).resolve().parent / "cloudflare.py")
|
||||
cfb = importlib.util.module_from_spec(cfs)
|
||||
cfs.loader.exec_module(cfb)
|
||||
good, detail = cfb.check()
|
||||
row("(optional) CLOUDFLARE_API_TOKEN (venture backends)", good, detail)
|
||||
try:
|
||||
models = cursor("GET", "/v1/models")
|
||||
items = models.get("models") or models.get("data") or models.get("items") or (models if isinstance(models, list) else [])
|
||||
|
||||
+85
-9
@@ -11,6 +11,9 @@ Plan: every board seat writes a launch plan (positioning, first users, pricing,
|
||||
synthesizes them, and LAUNCH.md records which seat each idea came from.
|
||||
Build: the Venice worker builds the product as the page on the Pages branch: the tool fills the first screen under a
|
||||
slim header with the one signup button; pricing, limits and the why frame it below. Check: deterministic gates, then headless Chrome at phone and desktop size; two fix rounds, then andon.
|
||||
Backend (optional, CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID): each venture also gets a Cloudflare Worker with its
|
||||
own D1 database and Workers AI, serving the Pages folder under <slug>.<TIME_VENTURE_DOMAIN> (cloudflare.py). The page
|
||||
reaches it through time.js; every call degrades to null, so the product works on Pages alone and in the checks.
|
||||
Signup: a GitHub issue form (.github/ISSUE_TEMPLATE/early-access.yml) prefilled with the venture and the plan.
|
||||
Measure: after 7 days, distinct people who signed up or 👍'd the signal issue, against the plan's success and kill
|
||||
lines: iterate (a v2 card), kill, or watch one more week and then park.
|
||||
@@ -41,6 +44,9 @@ HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
|
||||
tm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tm)
|
||||
_spec = importlib.util.spec_from_file_location("cfb", HERE / "cloudflare.py")
|
||||
cfb = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(cfb)
|
||||
REPO = tm.REPO
|
||||
PAGES_BRANCH = "gh-pages"
|
||||
FORM = "early-access.yml"
|
||||
@@ -382,7 +388,7 @@ For: {for_whom}
|
||||
The product (free, works entirely in the page): {page}
|
||||
The next version (the paid idea; do not build it): {next}
|
||||
Positioning: {positioning}
|
||||
{prior}
|
||||
{prior}{backend}
|
||||
Write these files on this branch under ventures/{slug}/ (relative links only). The launch plan is already there
|
||||
in ventures/{slug}/LAUNCH.md.
|
||||
- app.js: the product itself, working, in plain JavaScript (no libraries from other sites).
|
||||
@@ -446,6 +452,22 @@ The signal issue is {signal}
|
||||
When the fixes are written, call open_pr to finish (nothing is opened)."""
|
||||
|
||||
|
||||
BACKEND = """
|
||||
This venture has a backend at {url} (its own domain; the same page is also on GitHub Pages). ventures/{slug}/time.js is
|
||||
already in your folder: load it with <script src="time.js"></script> before app.js, and never write it. It gives:
|
||||
- Time.save(obj) -> a share link to this page that brings obj back, or null
|
||||
- Time.load() -> the object the current share link carried, or null (call it on load and open that creation if present)
|
||||
- Time.ai(prompt, {{system}}) -> text from a small language model, or null (rate limited; a few hundred calls a day)
|
||||
Every call can return null (offline, over the limit, and always during the checks, which run the page locally), so the
|
||||
product must work fully without them: use them for extras such as a "Share link" button that saves the user's creation,
|
||||
or an AI assist, and hide or disable that extra when the call gives null. The preloaded sample never depends on them.
|
||||
"""
|
||||
|
||||
|
||||
def backend_brief(w: dict) -> str:
|
||||
return BACKEND.format(url=w["url"], slug=w["slug"]) if w.get("backend") else ""
|
||||
|
||||
|
||||
def build_body(w: dict, plan: dict) -> str:
|
||||
rides_txt = "\n".join(f" - {r['name']} ({r['stars']:,} stars): https://github.com/{r['name']}" for r in w.get("rides", []))
|
||||
tiers = []
|
||||
@@ -457,7 +479,7 @@ def build_body(w: dict, plan: dict) -> str:
|
||||
return BUILD_BODY.format(name=w["name"], one_liner=w.get("one_liner", ""), for_whom=w.get("for_whom", ""),
|
||||
page=w.get("page", ""), next=w.get("next", ""), positioning=plan["positioning"], prior=prior,
|
||||
slug=w["slug"], url=w["url"], cta=plan["primary_cta"], join=w["join"], rides=rides_txt or " (none)",
|
||||
tiers="\n".join(tiers), repo=REPO, signal=signal_url(w))
|
||||
tiers="\n".join(tiers), repo=REPO, signal=signal_url(w), backend=backend_brief(w))
|
||||
|
||||
|
||||
def launch_md(w: dict, plan: dict, plans: dict) -> str:
|
||||
@@ -648,16 +670,18 @@ def index_html(reg: dict) -> str:
|
||||
rows = []
|
||||
vs = sorted(reg["ventures"], key=lambda v: (v.get("planned", ""), v.get("card", 0)), reverse=True)
|
||||
for k, v in enumerate(vs):
|
||||
links = [f'<a href="{e(v["join"])}">Join early access</a>' if v.get("join") else "",
|
||||
home = v["url"] if v.get("backend") else f'{v["slug"]}/' # its own domain once it has a backend
|
||||
links = [f'<a href="{e(home)}">{e(urllib.parse.urlsplit(home).netloc)}</a>' if v.get("backend") else "",
|
||||
f'<a href="{e(v["join"])}">Join early access</a>' if v.get("join") else "",
|
||||
f'<a href="https://github.com/{REPO}/issues/{v["signal"]}">Signal #{v["signal"]}</a>' if v.get("signal") else "",
|
||||
f'<a href="https://github.com/{REPO}/blob/{PAGES_BRANCH}/ventures/{e(v["slug"])}/LAUNCH.md">Launch plan</a>',
|
||||
f'<a href="https://github.com/{REPO}/issues/{v["card"]}">Card #{v["card"]}</a>' if v.get("card") else ""]
|
||||
status = v.get("status", "building")
|
||||
since = f"since {v['launched']}" if v.get("launched") else f"planned {v.get('planned', '')}"
|
||||
shot = (f'<a class="shot" href="{e(v["slug"])}/"><img src="{e(v["slug"])}/shots/sheet.png" alt="{e(v.get("name", v["slug"]))} '
|
||||
shot = (f'<a class="shot" href="{e(home)}"><img src="{e(v["slug"])}/shots/sheet.png" alt="{e(v.get("name", v["slug"]))} '
|
||||
f'at phone and desktop size" loading="lazy" onerror="this.parentNode.remove()"></a>') if v.get("launched") else ""
|
||||
rows.append(f"""<article class="row sheet"><div class="no">{len(vs) - k:03d}</div>
|
||||
<div><h2><a href="{e(v['slug'])}/">{e(v.get('name', v['slug']))}</a></h2><p>{e(v.get('one_liner', ''))}</p>
|
||||
<div><h2><a href="{e(home)}">{e(v.get('name', v['slug']))}</a></h2><p>{e(v.get('one_liner', ''))}</p>
|
||||
<p class="for">For: {e(v.get('for_whom', ''))}</p><p class="links">{' · '.join(x for x in links if x)}</p>{shot}</div>
|
||||
<div><span class="tag {e(status)}">{e(status)}</span><small>{e(since)}</small></div>
|
||||
<div class="count"><b>{int(v.get('signups', 0))}</b><small>signups</small></div></article>""")
|
||||
@@ -722,7 +746,8 @@ def check_page(w: dict, plan: dict) -> list[str]:
|
||||
files = {a: pages_file(f"ventures/{slug}/{a.lstrip('./')}") or "" for a in dict.fromkeys(local)}
|
||||
assets = "".join(files.values())
|
||||
low, problems = page.lower(), []
|
||||
code = "".join(re.findall(r"<script\b[^>]*>(.*?)</script>", page, re.S | re.I)) + "".join(v for a, v in files.items() if a.endswith(".js"))
|
||||
code = "".join(re.findall(r"<script\b[^>]*>(.*?)</script>", page, re.S | re.I)) + \
|
||||
"".join(v for a, v in files.items() if a.endswith(".js") and Path(a).name != "time.js") # Time's client isn't the product
|
||||
if len(re.sub(r"\s+", "", code)) < 800:
|
||||
problems.append("no working product: the page has almost no JavaScript. The product must run in the page and show real "
|
||||
"output from a preloaded sample")
|
||||
@@ -1224,7 +1249,8 @@ def advance(card: dict, comments: list[dict], beats: list[dict], heavy_ok: bool)
|
||||
w["slug"] = f"{w['slug']}-{n}" # never overwrite another venture's page
|
||||
site = ensure_pages()
|
||||
w["card"] = n
|
||||
w["url"] = f"{site.rstrip('/')}/ventures/{w['slug']}/"
|
||||
w["url"] = w["pages"] = f"{site.rstrip('/')}/ventures/{w['slug']}/"
|
||||
backend_note = give_backend(w)
|
||||
w["design"] = w.get("design") or PRODUCT_FIRST
|
||||
w["join"] = join_url(w)
|
||||
plan, plans = plan_round(w, board_seats, chair)
|
||||
@@ -1234,11 +1260,13 @@ def advance(card: dict, comments: list[dict], beats: list[dict], heavy_ok: bool)
|
||||
put_pages(f"ventures/{w['slug']}/LAUNCH.md", launch_md(w, plan, plans), f"venture {w['slug']}: launch plan")
|
||||
version = int(w.get("version", 1))
|
||||
entry = {"slug": w["slug"], "name": w["name"], "one_liner": w["one_liner"], "for_whom": w["for_whom"], "url": w["url"],
|
||||
"join": w["join"], "signal": w["signal"], "card": n, "design": w["design"], "version": version}
|
||||
"pages": w["pages"], "join": w["join"], "signal": w["signal"], "card": n, "design": w["design"], "version": version}
|
||||
if w.get("backend"):
|
||||
entry["backend"] = w["backend"]
|
||||
entry.update({"planned": today(), "status": "building", "signups": 0} if version == 1 else {"status": "iterating"})
|
||||
upsert(reg, entry)
|
||||
save_registry(reg, f"ventures: plan {w['slug']}")
|
||||
say(plan_summary(w, plan, plans, chair), {"beat": "planned", "winner": w, "plan": plan})
|
||||
say(plan_summary(w, plan, plans, chair) + backend_note, {"beat": "planned", "winner": w, "plan": plan})
|
||||
elif stage == "planned":
|
||||
if not heavy_ok:
|
||||
return "wait"
|
||||
@@ -1355,6 +1383,50 @@ def measure(n: int, st: dict, say) -> None:
|
||||
tm.gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"})
|
||||
|
||||
|
||||
# ---------- backends on Cloudflare (optional) ----------
|
||||
|
||||
def give_backend(w: dict) -> str:
|
||||
"""Deploy the venture's Worker, database and domain, and write its time.js. On success w["url"] becomes the domain
|
||||
(w["pages"] keeps the Pages folder). A failure never holds the venture: it launches on Pages and the chores retry."""
|
||||
if not cfb.enabled():
|
||||
return ""
|
||||
try:
|
||||
be = cfb.deploy(w["slug"], w["pages"])
|
||||
put_pages(f"ventures/{w['slug']}/time.js", cfb.client(be["api"]), f"venture {w['slug']}: backend client")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"\n\n_Backend: Cloudflare didn't take it ({str(e)[:200]}); launching on Pages, and the next tick tries again._"
|
||||
w["url"], w["backend"] = be["url"], {k: be[k] for k in ("worker", "db", "rev", "api")}
|
||||
return f"\n\n☁️ Backend: {be['url']} (Worker `{be['worker']}`, its own D1 database, Workers AI); the page is also on {w['pages']}"
|
||||
|
||||
|
||||
def refresh_backends(reg: dict, limit: int = 2) -> None:
|
||||
"""Keep every venture's backend on the current worker: deploy where it's missing or out of date (a new worker.js
|
||||
from Time's update, or new settings), a couple per tick. Killed ventures keep whatever they had."""
|
||||
if not cfb.enabled():
|
||||
return
|
||||
done, changed = 0, False
|
||||
for v in reg["ventures"]:
|
||||
if done >= limit or v.get("status") == "killed":
|
||||
continue
|
||||
pages = v.get("pages") or v.get("url")
|
||||
if not pages or not v.get("slug"):
|
||||
continue
|
||||
current = cfb.rev({**cfb.settings(v["slug"], pages, cfb.pages_origin_of(pages))})
|
||||
if (v.get("backend") or {}).get("rev") == current:
|
||||
continue
|
||||
done += 1
|
||||
try:
|
||||
be = cfb.deploy(v["slug"], pages)
|
||||
put_pages(f"ventures/{v['slug']}/time.js", cfb.client(be["api"]), f"venture {v['slug']}: backend client")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"backend {v['slug']} failed:", str(e)[:200])
|
||||
continue
|
||||
v.update(pages=pages, url=be["url"], backend={k: be[k] for k in ("worker", "db", "rev", "api")})
|
||||
changed = True
|
||||
if changed:
|
||||
save_registry(reg, "ventures: backends on Cloudflare")
|
||||
|
||||
|
||||
# ---------- the tick ----------
|
||||
|
||||
def tick() -> int:
|
||||
@@ -1365,6 +1437,7 @@ def tick() -> int:
|
||||
reg = registry()
|
||||
ack_signups(reg)
|
||||
refresh_daily(reg)
|
||||
refresh_backends(reg)
|
||||
except Exception as e: # noqa: BLE001 chores never block the cards
|
||||
print("chores failed:", e)
|
||||
fresh = []
|
||||
@@ -1417,6 +1490,9 @@ def _build_on_pages(card: dict, done: str, spec: str, root: str = "") -> dict:
|
||||
if name == "open_pr":
|
||||
self.pr = -1 # truthy sentinel: nothing to open, the page is live on the branch
|
||||
return "done: the page is on the Pages branch, no PR needed"
|
||||
if root and name == "write_file" and os.path.normpath(str(a.get("path") or "")) == os.path.normpath(root + "time.js"):
|
||||
self.steps += 1
|
||||
return "refused: time.js is Time's backend client and stays as it is; load it, don't write it"
|
||||
if root and name == "write_file" and not _inside(a.get("path"), root):
|
||||
self.steps += 1
|
||||
return f"refused: this build writes only under {root} (not {a.get('path')!r}); write the file there"
|
||||
|
||||
@@ -18,6 +18,9 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
|
||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
- name: copy engine, open PR if changed
|
||||
run: |
|
||||
set -e
|
||||
for f in .github/time/time.py .github/time/venture.py .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do
|
||||
for f in .github/time/time.py .github/time/venture.py .github/time/cloudflare.py .github/time/cloudflare/worker.js .github/time/cloudflare/time.js .github/time/cloudflare/test_worker.mjs .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do
|
||||
mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f"
|
||||
done
|
||||
python3 .github/time/test_time.py > /dev/null
|
||||
wf=$(for f in /tmp/up/.github/workflows/*.yml; do b=.github/workflows/$(basename "$f"); cmp -s "$f" "$b" || echo "- \`$b\`"; done)
|
||||
if git diff --quiet; then
|
||||
if git diff --quiet && [ -z "$(git status --porcelain .github/time)" ]; then
|
||||
echo "Time engine is up to date." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
[ -n "$wf" ] && printf 'Workflow files differ upstream, copy by hand:\n%s\n' "$wf" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
sha=$(git -C /tmp/up rev-parse --short HEAD)
|
||||
branch="time-update-$sha"
|
||||
git switch -c "$branch"
|
||||
git add .github/time .github/ISSUE_TEMPLATE .claude/skills/time
|
||||
git -c user.name=time -c user.email=time@users.noreply.github.com commit -qam "Update Time to $UPSTREAM@$sha"
|
||||
git push -q origin "$branch"
|
||||
body="Fresh Time engine from \`$UPSTREAM@$CHANNEL\` ($sha). Offline tests pass."
|
||||
|
||||
@@ -33,6 +33,10 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # optional: a backend per venture (cloudflare.py)
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
|
||||
TIME_VENTURE_AI_MODEL: ${{ vars.TIME_VENTURE_AI_MODEL }}
|
||||
TIME_VENTURE: ${{ vars.TIME_VENTURE }}
|
||||
TIME_VENTURE_START: ${{ inputs.start && '1' || '' }}
|
||||
TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }}
|
||||
|
||||
Reference in new issue
Block a user