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.