From 134a03ce41e375d7edb831e5a63f218d5e8abe7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:36:12 +0000 Subject: [PATCH] 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-) with its own D1 database and Workers AI. The Worker serves the venture's GitHub Pages folder under . (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 Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr --- .claude/skills/time/SKILL.md | 8 ++ .github/time/cloudflare.py | 177 ++++++++++++++++++++++++ .github/time/cloudflare/test_worker.mjs | 107 ++++++++++++++ .github/time/cloudflare/time.js | 35 +++++ .github/time/cloudflare/worker.js | 110 +++++++++++++++ .github/time/test_time.py | 177 ++++++++++++++++++++++++ .github/time/time.py | 6 + .github/time/venture.py | 94 +++++++++++-- .github/workflows/doctor.yml | 3 + .github/workflows/update.yml | 5 +- .github/workflows/venture.yml | 4 + CHANGELOG.md | 1 + README.md | 1 + 13 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 .github/time/cloudflare.py create mode 100644 .github/time/cloudflare/test_worker.mjs create mode 100644 .github/time/cloudflare/time.js create mode 100644 .github/time/cloudflare/worker.js diff --git a/.claude/skills/time/SKILL.md b/.claude/skills/time/SKILL.md index 5d91865..3544458 100644 --- a/.claude/skills/time/SKILL.md +++ b/.claude/skills/time/SKILL.md @@ -119,3 +119,11 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily - Settings: `TIME_VENTURE_PITCH`, `TIME_VENTURE_BOARD` (comma-separated seats), `TIME_VENTURE_CHAIR` (synthesizes the launch plans), `TIME_VENTURE_MEASURE_DAYS` (default 7). - A page that still fails its checks after two fix rounds gets `needs-human`: fix it on `gh-pages` and comment `/time retry` on the card. A Venice outage only makes a stage wait; it never closes a card. - The studio index is generated at `/ventures/` from `ventures/ventures.json`; launch posts are drafts on the card, never sent. + +### Backends on Cloudflare (optional) + +With a Cloudflare API token, each venture also gets a backend: its own Worker (`time-`, code in `.github/time/cloudflare/worker.js`), its own D1 database and Workers AI, serving the venture's Pages folder under its own domain. The page reaches it through `time.js` (written next to `index.html`; the build never edits it): `Time.save` / `Time.load` for share links, `Time.ai` for a small model. Every call degrades to null, so a product still works on Pages alone and in the headless checks. +- Token: Cloudflare → My Profile → API Tokens → Create Token (custom) with Account · Workers Scripts · Edit, Account · D1 · Edit, Account · Workers AI · Read, and, for a domain, Zone · Workers Routes · Edit and Zone · DNS · Edit on that zone. Then `gh secret set CLOUDFLARE_API_TOKEN` and `gh secret set CLOUDFLARE_ACCOUNT_ID` (the account id is in the dashboard URL). +- Domain: `gh variable set TIME_VENTURE_DOMAIN --body example.com` (a zone on that account) puts each venture at `.example.com`. Without it, ventures live at `time-..workers.dev`. +- Optional: `TIME_VENTURE_AI_MODEL` (default `@cf/meta/llama-3.1-8b-instruct`). +- The doctor checks the token. Existing ventures get backends a couple per tick, and a new `worker.js` (from Time's update) redeploys every venture the same way. A Cloudflare failure never holds a venture: it launches on Pages and the next tick tries again. diff --git a/.github/time/cloudflare.py b/.github/time/cloudflare.py new file mode 100644 index 0000000..17278a7 --- /dev/null +++ b/.github/time/cloudflare.py @@ -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 + +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-` 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 .; 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'), '')} " + 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 . 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 {'.' + 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__) diff --git a/.github/time/cloudflare/test_worker.mjs b/.github/time/cloudflare/test_worker.mjs new file mode 100644 index 0000000..7a04c76 --- /dev/null +++ b/.github/time/cloudflare/test_worker.mjs @@ -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(`${url}`, { 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"); diff --git a/.github/time/cloudflare/time.js b/.github/time/cloudflare/time.js new file mode 100644 index 0000000..099c837 --- /dev/null +++ b/.github/time/cloudflare/time.js @@ -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; }); + } + }; +})(); diff --git a/.github/time/cloudflare/worker.js b/.github/time/cloudflare/worker.js new file mode 100644 index 0000000..91c8d18 --- /dev/null +++ b/.github/time/cloudflare/worker.js @@ -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-) 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); + }, +}; diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 4e1f7c6..9ad7189 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -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 .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"] = ('' + + '' + "" * 400 + "") +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") diff --git a/.github/time/time.py b/.github/time/time.py index 75dade5..b8ae849 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -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 []) diff --git a/.github/time/venture.py b/.github/time/venture.py index 2b3f1d7..57776c3 100644 --- a/.github/time/venture.py +++ b/.github/time/venture.py @@ -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 . (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 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'Join early access' 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'{e(urllib.parse.urlsplit(home).netloc)}' if v.get("backend") else "", + f'Join early access' if v.get("join") else "", f'Signal #{v["signal"]}' if v.get("signal") else "", f'Launch plan', f'Card #{v["card"]}' 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'{e(v.get({e(v.get(') if v.get("launched") else "" rows.append(f"""""") @@ -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"]*>(.*?)", page, re.S | re.I)) + "".join(v for a, v in files.items() if a.endswith(".js")) + code = "".join(re.findall(r"]*>(.*?)", 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" diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml index f9d6f87..241e019 100644 --- a/.github/workflows/doctor.yml +++ b/.github/workflows/doctor.yml @@ -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 }} diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index e3404df..80d98b7 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -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." diff --git a/.github/workflows/venture.yml b/.github/workflows/venture.yml index f3c8e22..a59f176 100644 --- a/.github/workflows/venture.yml +++ b/.github/workflows/venture.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9379bba..f6b482a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials. ### Added +- Ventures can be backed by Cloudflare: with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, each venture gets its own Worker, D1 database and Workers AI, and lives at `.` (or workers.dev) while its page stays on GitHub Pages. The page's `time.js` gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token. - Venture loop launches products, not pages: a launch round where board seats write plans and one synthesizes `LAUNCH.md` with attribution; launch pages where the working product fills the first screen under a slim header with one call to action, then pricing and honest limits, in a look that follows the product; early-access signups through a GitHub issue form, thanked on their issues; deterministic and headless-Chrome checks with fix rounds and an andon; a 7-day decision against success and kill lines; a studio index at `/ventures/`. - Start a venture whenever you like: Actions → venture → Run workflow → "Start a new venture now" (`gh workflow run venture.yml -f start=true`); the schedule still starts one a day. - The board's taste: a delight anyone gets within a minute ranks above another developer utility, and when a product makes something people read (a deck, a card), its preloaded sample is the product's own pitch. diff --git a/README.md b/README.md index 401b63e..0e6ae7a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t - **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice). - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures//`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo. +- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `.`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`. - **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys. - **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.