From db7d937d9fa639257da36922b8c9b2be02bbcd7f Mon Sep 17 00:00:00 2001 From: Quentin Date: Thu, 8 Oct 2026 18:35:05 +0200 Subject: [PATCH] Give each project its own path space, name a call with no body as empty, and show weft's own calls that keep failing - Body-less POSTs (a run handed to a worker, a role's tick, the CLI's empty POSTs, a token exchange) send Content-Length: 0. Google's front end refused them with 411 over HTTP/1.1, which left every cloud install unable to start infra or run anything. They stay HTTP/1.1: HTTP/2 would put a worker's held run calls on one connection capped at 100. - A route on the install's shared address sits under its project: /connect///. Two projects may serve the same path; the overlap check compares one project's triggers. Stored routes are rewritten by a data migration. - weft's calls that keep failing (a role it cannot wake, workers it cannot hand a run to) are kept in unanswered_call and shown by weft status, weft infra status and the infra wait line. - Activate while infra is changing names the infra as the blocker. - Terraform makes Cloud Logging's service agent before the sink that writes as it. - The assistant setups say to run independent work on parallel agents. - Docs, Tangle skills and the connect library speak the new address; both extensions bumped. --- .claude/CLAUDE.md | 2 +- .codex/config.toml | 2 +- .kilo/agent/working-partner.md | 2 +- crates/weft-access-store/src/resolve.rs | 2 + crates/weft-broker/src/door.rs | 7 +- crates/weft-cli/src/client.rs | 9 +- crates/weft-cli/src/commands/infra.rs | 32 +-- crates/weft-cli/src/commands/status.rs | 18 +- crates/weft-cli/src/progress.rs | 2 +- crates/weft-core/src/infra/wire.rs | 5 + crates/weft-core/src/net.rs | 70 ++++++- crates/weft-core/src/primitive.rs | 12 +- crates/weft-core/src/projects.rs | 38 ++++ crates/weft-core/src/route.rs | 76 ++++++- crates/weft-dispatcher/src/api/infra.rs | 2 + crates/weft-dispatcher/src/api/mod.rs | 4 +- crates/weft-dispatcher/src/api/project.rs | 41 ++++ crates/weft-dispatcher/src/api/signal.rs | 191 +++++++++--------- crates/weft-dispatcher/src/app.rs | 3 + crates/weft-dispatcher/src/delivery.rs | 23 ++- crates/weft-dispatcher/src/door.rs | 16 +- crates/weft-dispatcher/src/live_relay.rs | 8 +- .../src/task_kinds/register_signal.rs | 122 +++++------ crates/weft-dispatcher/tests/db_lifecycle.rs | 2 +- crates/weft-e2e/src/live.rs | 2 +- crates/weft-e2e/src/project.rs | 17 +- crates/weft-e2e/tests/api_auth.rs | 51 ++--- crates/weft-e2e/tests/api_reply.rs | 2 +- crates/weft-runtime/src/lib.rs | 5 +- crates/weft-runtime/src/role_waker.rs | 37 +++- ...c_event_routes_sit_under_their_project.sql | 8 + .../migrations/unanswered_call/origin.sql | 10 + crates/weft-task-store/src/lib.rs | 3 + crates/weft-task-store/src/unanswered.rs | 113 +++++++++++ crates/weft-task-store/tests/unanswered.rs | 41 ++++ deploy/terraform/gcp/machines.tf | 14 ++ deploy/terraform/gcp/versions.tf | 7 +- docs/src/build/public-address.md | 4 +- docs/src/language/triggers-and-routes.md | 15 +- docs/src/running/architecture.md | 6 +- docs/src/running/cloud.md | 10 +- docs/src/running/instances.md | 2 +- extension-browser/package.json | 2 +- extension-vscode/package.json | 2 +- packages/weft-connect/README.md | 2 +- packages/weft-connect/src/core/socket.test.ts | 20 +- packages/weft-connect/src/core/socket.ts | 2 +- .../src/server/passthrough.test.ts | 4 +- .../weft-connect/src/server/passthrough.ts | 2 +- scripts/load-route.sh | 4 +- tangle/claude-code/.claude/agents/deployer.md | 2 +- .../.claude/skills/weft-api/SKILL.md | 12 +- .../.claude/skills/weft-deploying/SKILL.md | 7 +- .../.claude/skills/weft-frontend/SKILL.md | 4 +- tangle/claude-code/CLAUDE.md | 2 +- tangle/cline/.cline/skills/deployer/SKILL.md | 2 +- tangle/cline/.cline/skills/weft-api/SKILL.md | 12 +- .../.cline/skills/weft-deploying/SKILL.md | 7 +- .../.cline/skills/weft-frontend/SKILL.md | 4 +- tangle/cline/.clinerules/tangle.md | 2 +- tangle/codex/.agents/skills/weft-api/SKILL.md | 12 +- .../.agents/skills/weft-deploying/SKILL.md | 7 +- .../.agents/skills/weft-frontend/SKILL.md | 4 +- tangle/codex/.codex/agents/deployer.toml | 2 +- tangle/codex/AGENTS.md | 2 +- tangle/cursor/.cursor/agents/deployer.md | 2 +- tangle/cursor/.cursor/rules/tangle.mdc | 2 +- .../cursor/.cursor/skills/weft-api/SKILL.md | 12 +- .../.cursor/skills/weft-deploying/SKILL.md | 7 +- .../.cursor/skills/weft-frontend/SKILL.md | 4 +- tangle/devin-desktop/.devin/rules/tangle-3.md | 2 +- .../.windsurf/skills/deployer/SKILL.md | 2 +- .../.windsurf/skills/weft-api/SKILL.md | 12 +- .../.windsurf/skills/weft-deploying/SKILL.md | 7 +- .../.windsurf/skills/weft-frontend/SKILL.md | 4 +- .../fallback/.agents/skills/deployer/SKILL.md | 2 +- .../fallback/.agents/skills/weft-api/SKILL.md | 12 +- .../.agents/skills/weft-deploying/SKILL.md | 7 +- .../.agents/skills/weft-frontend/SKILL.md | 4 +- tangle/fallback/AGENTS.md | 2 +- tangle/gemini-cli/.gemini/agents/deployer.md | 2 +- .../.gemini/skills/weft-api/SKILL.md | 12 +- .../.gemini/skills/weft-deploying/SKILL.md | 7 +- .../.gemini/skills/weft-frontend/SKILL.md | 4 +- tangle/gemini-cli/GEMINI.md | 2 +- .../.github/agents/deployer.agent.md | 2 +- .../.github/copilot-instructions.md | 2 +- .../.github/skills/weft-api/SKILL.md | 12 +- .../.github/skills/weft-deploying/SKILL.md | 7 +- .../.github/skills/weft-frontend/SKILL.md | 4 +- tangle/junie/.junie/AGENTS.md | 2 +- tangle/junie/.junie/agents/deployer.md | 2 +- tangle/junie/.junie/skills/weft-api/SKILL.md | 12 +- .../.junie/skills/weft-deploying/SKILL.md | 7 +- .../.junie/skills/weft-frontend/SKILL.md | 4 +- tangle/kilo-code/.kilo/agent/deployer.md | 2 +- tangle/kilo-code/.kilo/agent/tangle.md | 2 +- .../kilo-code/.kilo/skills/weft-api/SKILL.md | 12 +- .../.kilo/skills/weft-deploying/SKILL.md | 7 +- .../.kilo/skills/weft-frontend/SKILL.md | 4 +- tangle/opencode/.opencode/agents/deployer.md | 2 +- tangle/opencode/.opencode/agents/tangle.md | 2 +- .../.opencode/skills/weft-api/SKILL.md | 12 +- .../.opencode/skills/weft-deploying/SKILL.md | 7 +- .../.opencode/skills/weft-frontend/SKILL.md | 4 +- tangle/opencode/AGENTS.md | 2 +- 106 files changed, 913 insertions(+), 460 deletions(-) create mode 100644 crates/weft-task-store/migrations/exec_event/20261008T155350_exec_event_routes_sit_under_their_project.sql create mode 100644 crates/weft-task-store/migrations/unanswered_call/origin.sql create mode 100644 crates/weft-task-store/src/unanswered.rs create mode 100644 crates/weft-task-store/tests/unanswered.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1c503c80..f3d2061d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -34,7 +34,7 @@ Ask (below the line, with compressed context) in exactly two cases, [the fork ru The only valid reasons to end a turn are a true blocker or finished work. -Don't launch agents to "design a plan" when you already have full context from the conversation; write the plan directly. Use agents only when the [user] explicitly says to (review commands count). +**Parallelize with agents whenever the work splits.** Once a round's work is known and it holds pieces that do not depend on each other (separate crates, docs beside code, a set of copies to update, an investigation beside an edit), hand the independent pieces to agents without waiting on one before starting the next (background agents, or several in one message), and keep doing your own piece meanwhile; doing them one after another yourself is wasted time for the [user]. Each brief names the exact files, the change, and its crates per the [test scope] rule below, and pieces are split so no two touch the same file or build the same crate. Keep for yourself what needs the whole picture (the design, the merge, the final build and tests). If you catch yourself doing a second independent piece by hand while the first could have gone to an agent, write verbatim "Wait, this splits. Dispatching the independent parts." and dispatch them. Don't dispatch agents to "design a plan" when you already have full context from the conversation; write the plan directly. ## Boundaries diff --git a/.codex/config.toml b/.codex/config.toml index 5eb84be4..a374a691 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -54,7 +54,7 @@ Ask (below the line, with compressed context) in exactly two cases, [the fork ru The only valid reasons to end a turn are a true blocker or finished work. -Don't dispatch subagents to "design a plan" when you already have full context from the conversation; write the plan directly. Use subagents only when the [user] explicitly says to (the review commands count). +**Parallelize with subagents whenever the work splits.** Once a round's work is known and it holds pieces that do not depend on each other (separate crates, docs beside code, a set of copies to update, an investigation beside an edit), hand the independent pieces to subagents without waiting on one before starting the next (background subagents, or several in one message), and keep doing your own piece meanwhile; doing them one after another yourself is wasted time for the [user]. Each brief names the exact files, the change, and its crates per the [test scope] rule below, and pieces are split so no two touch the same file or build the same crate. Keep for yourself what needs the whole picture (the design, the merge, the final build and tests). If you catch yourself doing a second independent piece by hand while the first could have gone to a subagent, write verbatim "Wait, this splits. Dispatching the independent parts." and dispatch them. Don't dispatch subagents to "design a plan" when you already have full context from the conversation; write the plan directly. ## Boundaries diff --git a/.kilo/agent/working-partner.md b/.kilo/agent/working-partner.md index c0ebc077..111fdc6b 100644 --- a/.kilo/agent/working-partner.md +++ b/.kilo/agent/working-partner.md @@ -40,7 +40,7 @@ Ask (below the line, with compressed context) in exactly two cases, [the fork ru The only valid reasons to end a turn are a true blocker or finished work. -Don't dispatch subagents to "design a plan" when you already hold full context from the conversation; write the plan directly. Use subagents only when the [user] explicitly says to (the review commands count). +**Parallelize with subagents whenever the work splits.** Once a round's work is known and it holds pieces that do not depend on each other (separate crates, docs beside code, a set of copies to update, an investigation beside an edit), hand the independent pieces to subagents without waiting on one before starting the next (background subagents, or several in one message), and keep doing your own piece meanwhile; doing them one after another yourself is wasted time for the [user]. Each brief names the exact files, the change, and its crates per the [test scope] rule below, and pieces are split so no two touch the same file or build the same crate. Keep for yourself what needs the whole picture (the design, the merge, the final build and tests). If you catch yourself doing a second independent piece by hand while the first could have gone to a subagent, write verbatim "Wait, this splits. Dispatching the independent parts." and dispatch them. Don't dispatch subagents to "design a plan" when you already hold full context from the conversation; write the plan directly. ## Boundaries diff --git a/crates/weft-access-store/src/resolve.rs b/crates/weft-access-store/src/resolve.rs index 49dfc534..96ef3cba 100644 --- a/crates/weft-access-store/src/resolve.rs +++ b/crates/weft-access-store/src/resolve.rs @@ -15,6 +15,7 @@ use weft_core::access::events::EventsSpec; use weft_core::access::spec::{ lookup_path, percent_encode, AccessSpec, Acquisition, AppRegistration, Door, OAuthGrant, }; +use weft_core::net::EmptyBody; use weft_core::node::Lookup; use crate::flows::{apply_captures, expires_at_of, token_request}; @@ -869,6 +870,7 @@ pub(crate) async fn mint_and_exchange( .post(&url) .bearer_auth(&jwt) .header(reqwest::header::ACCEPT, "application/json") + .empty_body() .send() .await .map_err(|e| { diff --git a/crates/weft-broker/src/door.rs b/crates/weft-broker/src/door.rs index 431dd9d6..cb635619 100644 --- a/crates/weft-broker/src/door.rs +++ b/crates/weft-broker/src/door.rs @@ -84,7 +84,8 @@ async fn door_triggers( .fetch_all(&state.pool) .await .map_err(|e| unavailable_or_internal(anyhow::Error::from(e).context("read the project's triggers")))?; - let triggers = rows.iter().map(|row| door_trigger(row, tenant)).collect::>>().map_err(unavailable_or_internal)?; + let mount = weft_core::route::SharedMount::new(tenant, project); + let triggers = rows.iter().map(|row| door_trigger(row, mount)).collect::>>().map_err(unavailable_or_internal)?; Ok(Json(DoorTriggers { triggers })) } @@ -164,12 +165,12 @@ async fn door_park_fire( /// One trigger as its signal row arms it: a public entry with a mount is /// a route somebody calls, which must take a caller on the line. -fn door_trigger(row: &sqlx::postgres::PgRow, tenant: &str) -> anyhow::Result { +fn door_trigger(row: &sqlx::postgres::PgRow, mount: weft_core::route::SharedMount<'_>) -> anyhow::Result { let node_id: String = row.try_get("node_id")?; let surface_kind: String = row.try_get("surface_kind")?; let route = match row.try_get::, _>("mount_path")? { Some(mount_path) if surface_kind == "public_entry" => Some(DoorMount { - pattern: weft_core::route::pattern_of_mount_path(&mount_path, tenant), + pattern: mount.pattern_of(&mount_path), methods: row.try_get("mount_methods")?, }), _ => None, diff --git a/crates/weft-cli/src/client.rs b/crates/weft-cli/src/client.rs index 0275eca7..9ab702fa 100644 --- a/crates/weft-cli/src/client.rs +++ b/crates/weft-cli/src/client.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, RwLock}; use anyhow::Context; +use weft_core::net::EmptyBody; /// In an error's chain when the store refused because the upload is being /// completed by another caller right now @@ -90,9 +91,11 @@ impl DispatcherClient { if let Some(key) = self.current_bearer()? { builder = builder.bearer_auth(key); } - if let Some(body) = body { - builder = builder.json(body); - } + builder = match body { + Some(body) => builder.json(body), + None if [reqwest::Method::POST, reqwest::Method::PUT, reqwest::Method::PATCH].contains(&method) => builder.empty_body(), + None => builder, + }; builder.send().await.with_context(|| format!("{method} {url}")) } diff --git a/crates/weft-cli/src/commands/infra.rs b/crates/weft-cli/src/commands/infra.rs index bd0246e1..f6e001e0 100644 --- a/crates/weft-cli/src/commands/infra.rs +++ b/crates/weft-cli/src/commands/infra.rs @@ -622,28 +622,34 @@ async fn infra_status(ctx: &Ctx) -> Result<()> { for note in status.host_notes() { eprintln!("warning: {note}"); } + // Printed apart from the copies: a start stuck this way has none yet. + let now = crate::progress::now_unix() as i64; + for entry in &status.unanswered { + eprintln!("warning: {}", entry.describe(now)); + } Ok(()) } /// What each copy still starting is doing, one line each, for a wait's -/// breadcrumb; a status that cannot be read is that one line instead. +/// breadcrumb, after each of weft's own calls that keeps failing; a status that cannot be read is that one line instead. async fn starting_now(client: &crate::client::DispatcherClient, project_id: &str) -> Vec { let status = match infra_status_of(client, project_id).await { Ok(status) => status, Err(e) => return vec![format!("cannot read what each copy is doing ({e:#}); the wait goes on")], }; - status - .nodes - .iter() - .filter_map(|n| { - let progress = n.progress.as_ref()?; - let node = match &n.instance { - Some(instance) => format!("{} (instance {instance})", n.node), - None => n.node.clone(), - }; - Some(format!("{node}: {} {}", n.status, progress.describe_now())) - }) - .collect() + // A call of weft's own that keeps failing comes first: it is why + // nothing below moves. + let now = crate::progress::now_unix() as i64; + let unanswered = status.unanswered.iter().map(|u| u.describe(now)); + let copies = status.nodes.iter().filter_map(|n| { + let progress = n.progress.as_ref()?; + let node = match &n.instance { + Some(instance) => format!("{} (instance {instance})", n.node), + None => n.node.clone(), + }; + Some(format!("{node}: {} {}", n.status, progress.describe_now())) + }); + unanswered.chain(copies).collect() } /// Every infra copy of the project, and its state. diff --git a/crates/weft-cli/src/commands/status.rs b/crates/weft-cli/src/commands/status.rs index 58a6c961..1d95c202 100644 --- a/crates/weft-cli/src/commands/status.rs +++ b/crates/weft-cli/src/commands/status.rs @@ -225,13 +225,23 @@ pub async fn run(ctx: Ctx) -> Result<()> { if let (Some(execution_id), Some(status)) = (&execs.last_execution_id, &execs.last_status) { match execs.last_completed_at { Some(ts) => { - let age = unix_now().saturating_sub(ts); + let age = crate::progress::now_unix().saturating_sub(ts); println!(" last: {execution_id} ({status}, completed {age}s ago)"); } None => println!(" last: {execution_id} ({status}, in flight)"), } } print_drift(&ctx, &data.drift); + // One of weft's own calls for this project keeps failing (waking the + // supervisor, handing a run to the workers): why infra or runs do + // not move. + if !data.unanswered.is_empty() { + println!(" waiting on weft:"); + let now = crate::progress::now_unix() as i64; + for entry in &data.unanswered { + println!(" {}", entry.describe(now)); + } + } // What each trigger started lately: the one trace a run kept // unrecorded leaves when it goes well. if !data.runs.is_empty() { @@ -291,9 +301,3 @@ fn print_drift(ctx: &Ctx, drift: &ProjectDrift) { } } -fn unix_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock past UNIX_EPOCH") - .as_secs() -} diff --git a/crates/weft-cli/src/progress.rs b/crates/weft-cli/src/progress.rs index 97454ee8..cc1ca5d5 100644 --- a/crates/weft-cli/src/progress.rs +++ b/crates/weft-cli/src/progress.rs @@ -439,7 +439,7 @@ fn elapsed_text(seconds: u64) -> String { } } -fn now_unix() -> u64 { +pub(crate) fn now_unix() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock past UNIX_EPOCH") diff --git a/crates/weft-core/src/infra/wire.rs b/crates/weft-core/src/infra/wire.rs index dd8b5bf9..51333ef9 100644 --- a/crates/weft-core/src/infra/wire.rs +++ b/crates/weft-core/src/infra/wire.rs @@ -291,6 +291,11 @@ pub struct PerNodeRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfraStatus { pub nodes: Vec, + /// weft's own calls this infra's work waits on that keep failing (a + /// supervisor that cannot be woken never starts it). Empty when every + /// one goes through. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unanswered: Vec, } impl InfraStatus { diff --git a/crates/weft-core/src/net.rs b/crates/weft-core/src/net.rs index 881fab78..83fe0fd8 100644 --- a/crates/weft-core/src/net.rs +++ b/crates/weft-core/src/net.rs @@ -12,6 +12,8 @@ //! install answers at several addresses at once, so a link the caller //! will fetch is built from that caller's own request rather than from //! anything configured. +//! +//! OUTBOUND HTTP: [`EmptyBody`], for a call that sends nothing. use std::sync::Arc; @@ -111,7 +113,7 @@ pub fn tls_config() -> Result, String> { pub const WEFT_FORWARDED_PROTO: &str = "x-weft-forwarded-proto"; /// What weft's relay tells a project's worker about a call it passed on -/// from an address the install shares (`/connect//...`, an API +/// from an address the install shares (`/connect///...`, an API /// domain, a local project's port): where the caller stood, which the /// worker cannot see past the relay. A worker reads these only on a hop /// that carries weft's own credential, and removes them (with that @@ -124,7 +126,7 @@ pub mod relay_hop { /// replaces with the worker's own. pub const CALLER_HOST: &str = "x-weft-caller-host"; /// The path the project's routes sit under at that address - /// (`/connect/`, or empty at a project's own address): what a + /// (`/connect//`, or empty at a project's own address): what a /// browser's socket URL is built on. pub const ROUTE_PREFIX: &str = "x-weft-route-prefix"; /// Every one of them. @@ -237,6 +239,27 @@ pub fn caller_address(forwarded_for: Option<&str>, peer: std::net::IpAddr, trust chain[index] } +/// A call that sends no body, saying so with `Content-Length: 0`. +/// +/// Sent with no body at all, a POST goes out over HTTP/1.1 with no length, +/// and Google's front end, in front of every Cloud Run service, refuses +/// it with `411 Length Required` before the service ever sees it. A local +/// install has nothing in between, so only a cloud install meets it. +/// Offering HTTP/2 instead would get past the front end too, but HTTP/2 +/// puts every call to one service on one connection, which Cloud Run caps +/// at 100 calls at once: a call held for a run's whole life (a run handed +/// to a worker) would make the 101st wait. So weft's calls stay HTTP/1.1, +/// one connection each, and a call with nothing to send says its length. +pub trait EmptyBody { + fn empty_body(self) -> Self; +} + +impl EmptyBody for reqwest::RequestBuilder { + fn empty_body(self) -> Self { + self.header(reqwest::header::CONTENT_LENGTH, 0) + } +} + /// The first value of a header a chain of proxies may list comma-separated. fn first_hop(value: &http::HeaderValue) -> Option<&str> { let text = value.to_str().ok()?; @@ -484,3 +507,46 @@ mod caller_address_tests { assert_eq!(caller_address(Some("203.0.113.9"), ip("10.0.0.1"), 0), ip("10.0.0.1")); } } + +#[cfg(test)] +mod empty_body_tests { + use super::EmptyBody; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// A server on a free port that answers the way Google's front end + /// does: `411` to a POST that names no length, `204` to anything else. + async fn length_required_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + if socket.read(&mut byte).await.unwrap_or(0) == 0 { + return; + } + head.push(byte[0]); + } + let head = String::from_utf8_lossy(&head).to_ascii_lowercase(); + let status = match head.starts_with("post ") && !head.contains("\r\ncontent-length:") { + true => "411 Length Required", + false => "204 No Content", + }; + let _ = socket.write_all(format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").as_bytes()).await; + }); + } + }); + address + } + + #[tokio::test] + async fn a_post_with_nothing_to_send_names_its_length() { + let url = format!("{}/_weft/tick", length_required_server().await); + let http = reqwest::Client::new(); + assert_eq!(http.post(&url).send().await.unwrap().status(), 411, "the refusal this guards against"); + assert_eq!(http.post(&url).empty_body().send().await.unwrap().status(), 204); + } +} diff --git a/crates/weft-core/src/primitive.rs b/crates/weft-core/src/primitive.rs index 60971eb9..1b516381 100644 --- a/crates/weft-core/src/primitive.rs +++ b/crates/weft-core/src/primitive.rs @@ -145,13 +145,15 @@ impl From<&crate::access::Access> for AccessRef { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SignalSurface { - /// Author-controlled HTTP entrypoint. Mounted under the tenant at - /// the dispatcher root: external callers reach it at - /// `/connect//` (a live route) or - /// `POST //` (a plain public fire). + /// Author-controlled HTTP entrypoint. Mounted under the tenant and + /// the project at the dispatcher root + /// ([`crate::route::SharedMount`]): external callers reach it at + /// `/connect///` (a live route) + /// or `POST ///` (a plain + /// public fire). /// `path` is a route pattern (`chat/{room}`); `""` means the root. /// `methods` lists the HTTP methods served, uppercase; empty = any. - /// Two routes of one tenant may not overlap (same shape, a shared + /// Two routes of one project may not overlap (same shape, a shared /// method), checked at register time. Used by the live-caller kinds /// (Route, Socket) and any future public-form-like kind. PublicEntry { diff --git a/crates/weft-core/src/projects.rs b/crates/weft-core/src/projects.rs index a869e324..e5f03858 100644 --- a/crates/weft-core/src/projects.rs +++ b/crates/weft-core/src/projects.rs @@ -232,6 +232,44 @@ pub struct ProjectStatusResponse { /// leaves when it goes well. Empty when no trigger started one. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub runs: Vec, + /// weft's own calls the project's work waits on that keep failing. + /// Empty when every one goes through. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unanswered: Vec, +} + +/// One of weft's own calls that keeps failing, so the work behind it +/// waits: a role woken to pick up work (the supervisor, which starts and +/// stops infra), or the project's workers being handed a run. weft keeps +/// trying on its own; this is what a person waiting is shown. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Unanswered { + /// What does not answer, in a person's words, as the subject of a + /// sentence ("weft's supervisor, which starts and stops infra,"). + pub callee: String, + /// The last try's error. + pub error: String, + #[serde(rename = "sinceUnix")] + pub since_unix: i64, + #[serde(rename = "lastUnix")] + pub last_unix: i64, +} + +impl Unanswered { + /// One line for a person: what is not answering, since when, and why. + pub fn describe(&self, now_unix: i64) -> String { + let span = |secs: i64| match secs.max(0) { + secs @ 0..120 => format!("{secs}s"), + secs => format!("{} min", secs / 60), + }; + format!( + "{} cannot be reached (failing for {}, last tried {} ago; weft keeps trying): {}", + self.callee, + span(now_unix - self.since_unix), + span(now_unix - self.last_unix), + self.error, + ) + } } /// What `DELETE /projects/{id}` answers: what a forced removal could not diff --git a/crates/weft-core/src/route.rs b/crates/weft-core/src/route.rs index 7862e4f1..03d61066 100644 --- a/crates/weft-core/src/route.rs +++ b/crates/weft-core/src/route.rs @@ -229,13 +229,55 @@ impl RouteKey { } } -/// The pattern under the tenant prefix a stored mount path carries -/// (`/alice/chat/{room}` -> `chat/{room}`, `/alice` -> ``). Every row of a -/// tenant is prefixed the same way, so the strip is exact. -pub fn pattern_of_mount_path(mount_path: &str, tenant: &str) -> String { - let prefix = format!("/{tenant}"); - let rest = mount_path.strip_prefix(&prefix).unwrap_or(mount_path); - rest.trim_start_matches('/').to_string() +/// Where one project's routes sit on an address the install shares +/// between its projects: under the tenant, then the project's id +/// (`/alice/2c65.../chat/{room}`). The id is fixed in the project's +/// `weft.toml`, so the address is known before anything runs and two +/// projects may serve the same path. A project's own address (its port, +/// its API domain) serves the same routes at its root, with neither. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SharedMount<'a> { + pub tenant: &'a str, + pub project: uuid::Uuid, +} + +impl<'a> SharedMount<'a> { + pub fn new(tenant: &'a str, project: uuid::Uuid) -> Self { + Self { tenant, project } + } + + /// What every route of the project sits under (`/alice/`). + pub fn prefix(&self) -> String { + format!("/{}/{}", self.tenant, self.project) + } + + /// The stored mount path of a route `pattern` (`chat/{room}`, or empty + /// for the project's root). + pub fn mount_path(&self, pattern: &str) -> String { + match pattern.trim_start_matches('/') { + "" => self.prefix(), + pattern => format!("{}/{pattern}", self.prefix()), + } + } + + /// The pattern a stored mount path of this project carries + /// (`/alice//chat/{room}` -> `chat/{room}`, `/alice/` -> ``). + /// Every row of the project is prefixed the same way, so the strip is + /// exact. + pub fn pattern_of(&self, mount_path: &str) -> String { + let prefix = self.prefix(); + mount_path.strip_prefix(&prefix).unwrap_or(mount_path).trim_start_matches('/').to_string() + } + + /// A path called on the shared address (`alice//chat/7`, a leading + /// slash or none) as the project it names and the path under it. + /// `None` when it does not start with a tenant and a project id. + pub fn split(called: &'a str) -> Option<(Self, &'a str)> { + let mut parts = called.trim_start_matches('/').splitn(3, '/'); + let tenant = parts.next().filter(|t| !t.is_empty())?; + let project = parts.next()?.parse::().ok()?; + Some((Self { tenant, project }, parts.next().unwrap_or(""))) + } } /// What a lookup found. @@ -312,6 +354,26 @@ pub fn parse_query(raw: &str) -> BTreeMap { mod tests { use super::*; + #[test] + fn a_shared_mount_puts_routes_under_the_tenant_and_project_and_reads_them_back() { + let project = uuid::Uuid::from_u128(7); + let mount = SharedMount::new("alice", project); + assert_eq!(mount.mount_path("chat/{room}"), format!("/alice/{project}/chat/{{room}}")); + assert_eq!(mount.mount_path("/chat"), format!("/alice/{project}/chat")); + assert_eq!(mount.mount_path(""), format!("/alice/{project}")); + assert_eq!(mount.pattern_of(&mount.mount_path("chat/{room}")), "chat/{room}"); + assert_eq!(mount.pattern_of(&mount.mount_path("")), ""); + + let called = format!("alice/{project}/chat/7"); + assert_eq!(SharedMount::split(&called), Some((mount, "chat/7"))); + let root = format!("/alice/{project}"); + assert_eq!(SharedMount::split(&root), Some((mount, ""))); + // A path that names no project is no project's route. + assert_eq!(SharedMount::split("alice/chat/7"), None); + assert_eq!(SharedMount::split("alice"), None); + assert_eq!(SharedMount::split(""), None); + } + fn pat(s: &str) -> RoutePattern { RoutePattern::parse(s).expect("parses") } diff --git a/crates/weft-dispatcher/src/api/infra.rs b/crates/weft-dispatcher/src/api/infra.rs index 9b32dc6a..e5bc098b 100644 --- a/crates/weft-dispatcher/src/api/infra.rs +++ b/crates/weft-dispatcher/src/api/infra.rs @@ -197,6 +197,7 @@ pub(super) async fn sync_inner( finish_sync(&state, id, begun).await?; Ok(Json(InfraStatus { nodes: read_infra_entries(&state, id).await?, + unanswered: crate::api::project::unanswered_for(&state, id).await?, })) } @@ -1092,6 +1093,7 @@ pub async fn status( authorize_project(&state, &caller.0, id).await?; Ok(Json(InfraStatus { nodes: read_infra_entries(&state, id).await?, + unanswered: crate::api::project::unanswered_for(&state, id).await?, })) } diff --git a/crates/weft-dispatcher/src/api/mod.rs b/crates/weft-dispatcher/src/api/mod.rs index 52531fa7..18e23f0f 100644 --- a/crates/weft-dispatcher/src/api/mod.rs +++ b/crates/weft-dispatcher/src/api/mod.rs @@ -434,8 +434,8 @@ fn outside_caller_routes(state: DispatcherState) -> Router { .route("/index.html", get(public_page::index)) .route("/logo.png", get(public_page::logo)) // A live call at the install's shared address - // (`/connect//`): the route (pattern + method) picks - // the project, and the call is passed on to its workers, whose door + // (`/connect///`): the address names the + // project, and the call is passed on to its workers, whose door // checks it (`live_relay`). ANY method: // a WS handshake is a GET and a route serves whatever verbs it // declared; the handler answers 405 itself. `/connect/*` is more diff --git a/crates/weft-dispatcher/src/api/project.rs b/crates/weft-dispatcher/src/api/project.rs index aaa18ade..6469776e 100644 --- a/crates/weft-dispatcher/src/api/project.rs +++ b/crates/weft-dispatcher/src/api/project.rs @@ -1249,6 +1249,29 @@ async fn limited_entries(state: &DispatcherState, project_id: uuid::Uuid) -> any Ok(out) } +/// weft's own calls the project's work waits on that keep failing: every +/// role weft cannot wake, and the project's workers when runs cannot be +/// handed to them. +pub(crate) async fn unanswered_for(state: &DispatcherState, project_id: uuid::Uuid) -> Result, (StatusCode, String)> { + let failing = weft_task_store::unanswered::failing_for(&state.pg_pool, project_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("calls that keep failing: {e:#}")))?; + Ok(failing + .into_iter() + .map(|f| weft_core::projects::Unanswered { + callee: match f.role.as_deref() { + None => "this project's workers, which runs are handed to,".to_string(), + Some("supervisor") => "weft's supervisor, which starts and stops infra,".to_string(), + Some("dispatcher") => "weft's dispatcher, which hands runs to workers,".to_string(), + Some(role) => format!("weft's {role}"), + }, + error: f.error, + since_unix: f.since_ms / 1000, + last_unix: f.last_ms / 1000, + }) + .collect()) +} + /// The runs each of the project's triggers started in this minute and the /// one before, named by the trigger's node. async fn trigger_runs(state: &DispatcherState, project_id: uuid::Uuid) -> anyhow::Result> { @@ -1551,6 +1574,7 @@ pub async fn status( runs: trigger_runs(&state, id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("run counts: {e}")))?, + unanswered: unanswered_for(&state, id).await?, })) } @@ -1950,6 +1974,16 @@ fn unavailable_action( if let Some(why) = transition.refusal() { return format!("'{verb}' is not available right now: {why}"); } + // Infra being started, stopped or terminated blocks every verb but + // its cancel (the table's master rule), so the infra is named as the + // blocker rather than the triggers' state, which is not why. + if allowed == ["infra_cancel"] { + return format!( + "'{verb}' is not available right now: this program's infra is changing (infra {infra_rollup}). \ + `weft infra status` shows where each piece is and `weft infra cancel` stops the change; \ + run this again once it settles" + ); + } // Starting infra that already runs is somebody who changed it and wants // the change live (an upgrade) or who did not know it was up. if verb == "infra_start" && infra_rollup == "running" && allowed.iter().any(|a| a == "infra_upgrade") { @@ -5319,6 +5353,13 @@ mod unavailable_action_tests { assert!(leftover.contains("allowed actions: [infra_terminate]"), "{leftover}"); } + #[test] + fn infra_that_is_changing_is_named_as_the_blocker() { + let message = unavailable_action("activate", ProjectStatus::Registered, ProjectTransition::None, "provisioning", &["infra_cancel".to_string()]); + assert!(message.contains("infra is changing (infra provisioning)") && message.contains("weft infra status"), "{message}"); + assert!(!message.contains("registered"), "the triggers' state is not why: {message}"); + } + #[test] fn a_build_in_flight_is_named_as_the_blocker() { let allowed = vec!["cancel_build".to_string()]; diff --git a/crates/weft-dispatcher/src/api/signal.rs b/crates/weft-dispatcher/src/api/signal.rs index a244351b..95d3f256 100644 --- a/crates/weft-dispatcher/src/api/signal.rs +++ b/crates/weft-dispatcher/src/api/signal.rs @@ -1045,21 +1045,20 @@ async fn public_entry_target( .to_string(), ) }; - // The stored address is `//`, and a pattern is not + // The stored address is `///`, and a pattern is not // a string to compare: `cards/{id}` has to be MATCHED against // `cards/7`. This used to be an equality lookup, so a registered // address holding a capture could never be reached through here at // all, whatever was called. - let (tenant, called) = split_tenant(&normalized).map_err(|_| refuse())?; + let (mount, called) = weft_core::route::SharedMount::split(&normalized).ok_or_else(refuse)?; // Matched against the held routes, and against the rows themselves // before refusing: a route activated a moment ago may not have been // heard yet. - let rows_of = |routes: &[HeldRoute]| -> Vec { routes.iter().map(|route| route.row.clone()).collect() }; - let rows = match state.held.routes.held(&tenant.to_string()).map(|held| rows_of(&held)) { - Some(held) if resolve_route(&held, tenant, "POST", called).is_ok() => held, - _ => rows_of(&fresh_tenant_routes(state, tenant).await?), + let rows = match state.held.routes.held(&mount.tenant.to_string()).map(|held| rows_of(&held, mount.project)) { + Some(held) if resolve_route(&held, mount, "POST", called).is_ok() => held, + _ => rows_of(&fresh_tenant_routes(state, mount.tenant).await?, mount.project), }; - let (matched, params) = resolve_route(&rows, tenant, "POST", called).map_err(|_| refuse())?; + let (matched, params) = resolve_route(&rows, mount, "POST", called).map_err(|_| refuse())?; // An address with a capture in it is a live route's shape, and a // live route is served (and gated, and answered) at `/connect`. // Reached here it would fire the program with the capture thrown @@ -1088,7 +1087,12 @@ async fn public_entry_target( Ok((token, routing, payload)) } -/// One public-entry row of the tenant, as the route matcher sees it. +/// The rows of `project` among a tenant's held routes. +fn rows_of(routes: &[HeldRoute], project: uuid::Uuid) -> Vec { + routes.iter().filter(|route| route.project_id == project).map(|route| route.row.clone()).collect() +} + +/// One public-entry row of a project, as the route matcher sees it. #[derive(Debug, Clone)] pub(crate) struct RouteRow { pub mount_path: String, @@ -1099,20 +1103,20 @@ pub(crate) struct RouteRow { /// The route a call resolved to: its row and the path's captures. pub(crate) type ResolvedRoute<'r> = (&'r RouteRow, std::collections::BTreeMap); -/// Pick the row serving `method` on `path` among the tenant's public -/// entries, or the HTTP answer when none does: `404` for an unknown -/// path, `405` naming the allowed methods for a known path called with -/// the wrong verb. Pure over the rows (the SQL only narrows to the -/// tenant); a stored pattern that no longer parses is skipped, loud in -/// the log (its own register validated it, so this is corruption). +/// Pick the row serving `method` on `path` among one project's public +/// entries (`rows`, all under `mount`), or the HTTP answer when none does: +/// `404` for an unknown path, `405` naming the allowed methods for a known +/// path called with the wrong verb. Pure over the rows; a stored pattern +/// that no longer parses is skipped, loud in the log (its own register +/// validated it, so this is corruption). pub(crate) fn resolve_route<'r>( rows: &'r [RouteRow], - tenant: &str, + mount: weft_core::route::SharedMount<'_>, method: &str, path: &str, ) -> Result, (StatusCode, String)> { let candidates = rows.iter().filter_map(|row| { - let pattern = weft_core::route::pattern_of_mount_path(&row.mount_path, tenant); + let pattern = mount.pattern_of(&row.mount_path); match weft_core::route::RoutePattern::parse(&pattern) { Ok(pattern) => Some(( weft_core::route::RouteKey { pattern, methods: row.mount_methods.clone() }, @@ -1140,36 +1144,22 @@ pub(crate) fn resolve_route<'r>( } } -/// Split the called `/connect/{*path}` into the tenant segment and the -/// path under the project (no leading slash). The tenant is always the -/// first segment; a call with none is not a route anyone registered. -pub(crate) fn split_tenant(called: &str) -> Result<(&str, &str), (StatusCode, String)> { - let called = called.trim_start_matches('/'); - let (tenant, rest) = match called.split_once('/') { - Some((t, r)) => (t, r), - None => (called, ""), - }; - if tenant.is_empty() { - return Err((StatusCode::NOT_FOUND, "no live endpoint at this path".into())); - } - Ok((tenant, rest)) -} - // ----- Live callers at a shared address ----------------------------- /// Set by the install's door on a request that came for one project's API -/// domain: the relay then matches only that project's routes. The door -/// drops any copy a caller sent; one that reaches the relay some other way -/// can only narrow what matches. +/// domain, naming that project: the call sits at the domain's root, so the +/// worker is told no prefix. The door drops any copy a caller sent; one +/// that reaches the relay some other way must name the project the path +/// names, or nothing matches. // SYNC: API_PROJECT_HEADER <-> crates/weft-dispatcher/src/door.rs (route) pub const API_PROJECT_HEADER: &str = "x-weft-api-project"; /// `ANY /connect/{*path}`: a live call at an address the install shares -/// between its projects. The tenant's routes (pattern + method) pick the -/// project and the program serving the route, and the call is passed on to -/// that project's workers as the caller sent it (`live_relay`), whose door -/// runs every check. A path no route serves is answered here, so a stray -/// call wakes no worker. +/// between its projects, `/connect///`. The +/// project's routes (pattern + method) pick the program serving the route, +/// and the call is passed on to that project's workers as the caller sent +/// it (`live_relay`), whose door runs every check. A path no route serves +/// is answered here, so a stray call wakes no worker. pub async fn connect_live( State(state): State, address: crate::api::CallerAddress, @@ -1177,31 +1167,36 @@ pub async fn connect_live( RawQuery(raw_query): RawQuery, request: axum::extract::Request, ) -> Result { - let (tenant_segment, path) = split_tenant(&called_path)?; - let (tenant_segment, path) = (tenant_segment.to_string(), path.to_string()); - let only_project = match request.headers().get(API_PROJECT_HEADER) { - None => None, - Some(v) => Some( - v.to_str() + let no_endpoint = || (StatusCode::NOT_FOUND, "no live endpoint at this path".to_string()); + let (mount, path) = weft_core::route::SharedMount::split(&called_path).ok_or_else(no_endpoint)?; + let by_domain = match request.headers().get(API_PROJECT_HEADER) { + None => false, + Some(v) => { + let named = v + .to_str() .ok() .and_then(|v| v.parse::().ok()) - .ok_or((StatusCode::BAD_REQUEST, format!("{API_PROJECT_HEADER} is not a project id")))?, - ), + .ok_or((StatusCode::BAD_REQUEST, format!("{API_PROJECT_HEADER} is not a project id")))?; + if named != mount.project { + return Err(no_endpoint()); + } + true + } }; - let (project_id, binary_hash) = relayed_route(&state, &tenant_segment, only_project, request.method().as_str(), &path).await?; + let binary_hash = relayed_route(&state, mount, request.method().as_str(), path).await?; // The path as the caller sent it, still percent-encoded: the decoded // capture would turn an escaped `?`, `/` or `#` into a real one on the // way to the worker. A project's API domain serves its routes at its - // root; everything else sits under its tenant. - let mount = format!("/connect/{tenant_segment}"); + // root; everything else sits under its tenant and project. + let shared = format!("/connect{}", mount.prefix()); let raw_path = request .uri() .path() - .strip_prefix(&mount) - .ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, format!("a live call reached the relay at {}, outside {mount}", request.uri().path())))?; + .strip_prefix(&shared) + .ok_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, format!("a live call reached the relay at {}, outside {shared}", request.uri().path())))?; let raw_path = if raw_path.is_empty() { "/".to_string() } else { raw_path.to_string() }; - let prefix = if only_project.is_some() { String::new() } else { mount }; - Ok(crate::live_relay::to_project(&state, project_id, &binary_hash, address.0, &prefix, &raw_path, raw_query.as_deref().unwrap_or(""), request).await) + let prefix = if by_domain { String::new() } else { shared }; + Ok(crate::live_relay::to_project(&state, mount.project, &binary_hash, address.0, &prefix, &raw_path, raw_query.as_deref().unwrap_or(""), request).await) } /// One public entry of a tenant as held in memory (`crate::held`): where @@ -1212,42 +1207,38 @@ pub(crate) struct HeldRoute { binary_hash: String, } -/// The project and the program serving `method` on `path` among -/// `tenant`'s live routes (of `only_project`, when the call came by a -/// project's API domain). Matched against the routes this dispatcher holds, -/// and against the rows themselves before refusing: a route armed a moment -/// ago may not have been heard yet. +/// The program serving `method` on `path` among the live routes of the +/// project `mount` names. Matched against the routes this dispatcher +/// holds, and against the rows themselves before refusing: a route armed a +/// moment ago may not have been heard yet. async fn relayed_route( state: &DispatcherState, - tenant: &str, - only_project: Option, + mount: weft_core::route::SharedMount<'_>, method: &str, path: &str, -) -> Result<(uuid::Uuid, String), (StatusCode, String)> { - if let Some(held) = state.held.routes.held(&tenant.to_string()) { - if let Ok(found) = match_relayed(&held, tenant, only_project, method, path) { +) -> Result { + if let Some(held) = state.held.routes.held(&mount.tenant.to_string()) { + if let Ok(found) = match_relayed(&held, mount, method, path) { return Ok(found); } } - match_relayed(&fresh_tenant_routes(state, tenant).await?, tenant, only_project, method, path) + match_relayed(&fresh_tenant_routes(state, mount.tenant).await?, mount, method, path) } fn match_relayed( routes: &[HeldRoute], - tenant: &str, - only_project: Option, + mount: weft_core::route::SharedMount<'_>, method: &str, path: &str, -) -> Result<(uuid::Uuid, String), (StatusCode, String)> { - let candidates: Vec<&HeldRoute> = - routes.iter().filter(|route| only_project.is_none_or(|only| route.project_id == only)).collect(); +) -> Result { + let candidates: Vec<&HeldRoute> = routes.iter().filter(|route| route.project_id == mount.project).collect(); let rows: Vec = candidates.iter().map(|route| route.row.clone()).collect(); - let (matched, _) = resolve_route(&rows, tenant, method, path)?; + let (matched, _) = resolve_route(&rows, mount, method, path)?; let route = candidates .iter() .find(|route| route.row.token == matched.token) .expect("the matched route is one of the rows it was matched among"); - Ok((route.project_id, route.binary_hash.clone())) + Ok(route.binary_hash.clone()) } /// Every public entry of `tenant` as the rows say now, kept for the next @@ -1362,47 +1353,65 @@ mod route_lookup_tests { } } - #[test] - fn the_tenant_is_the_first_segment() { - assert_eq!(split_tenant("alice/chat/room7").unwrap(), ("alice", "chat/room7")); - assert_eq!(split_tenant("alice").unwrap(), ("alice", "")); - assert_eq!(split_tenant("/alice/").unwrap(), ("alice", "")); - assert_eq!(split_tenant("").unwrap_err().0, StatusCode::NOT_FOUND); + const P: uuid::Uuid = uuid::Uuid::from_u128(7); + + fn mount() -> weft_core::route::SharedMount<'static> { + weft_core::route::SharedMount::new("alice", P) + } + + /// A row of project `P` of tenant `alice`, at `rest` under it. + fn at(rest: &str, methods: &[&str], token: &str) -> RouteRow { + row(&mount().mount_path(rest), methods, token) } #[test] fn a_literal_route_beats_a_capture_and_params_come_back() { - let rows = vec![ - row("/alice/users/{id}", &[], "by-id"), - row("/alice/users/me", &[], "me"), - ]; - let (hit, params) = resolve_route(&rows, "alice", "GET", "users/me").unwrap(); + let rows = vec![at("users/{id}", &[], "by-id"), at("users/me", &[], "me")]; + let (hit, params) = resolve_route(&rows, mount(), "GET", "users/me").unwrap(); assert_eq!(hit.token, "me"); assert!(params.is_empty()); - let (hit, params) = resolve_route(&rows, "alice", "GET", "users/42").unwrap(); + let (hit, params) = resolve_route(&rows, mount(), "GET", "users/42").unwrap(); assert_eq!(hit.token, "by-id"); assert_eq!(params.get("id").map(String::as_str), Some("42")); } #[test] fn a_known_path_with_the_wrong_verb_is_405_naming_the_allowed_ones() { - let rows = vec![row("/alice/items", &["POST", "PUT"], "w"), row("/alice/items", &["DELETE"], "d")]; - let err = resolve_route(&rows, "alice", "GET", "items").unwrap_err(); + let rows = vec![at("items", &["POST", "PUT"], "w"), at("items", &["DELETE"], "d")]; + let err = resolve_route(&rows, mount(), "GET", "items").unwrap_err(); assert_eq!(err.0, StatusCode::METHOD_NOT_ALLOWED); assert!(err.1.contains("DELETE, POST, PUT"), "{}", err.1); } #[test] fn an_unknown_path_is_404_and_the_root_route_serves_the_empty_path() { - let rows = vec![row("/alice", &[], "root")]; - assert_eq!(resolve_route(&rows, "alice", "GET", "nothing").unwrap_err().0, StatusCode::NOT_FOUND); - assert_eq!(resolve_route(&rows, "alice", "GET", "").unwrap().0.token, "root"); + let rows = vec![at("", &[], "root")]; + assert_eq!(resolve_route(&rows, mount(), "GET", "nothing").unwrap_err().0, StatusCode::NOT_FOUND); + assert_eq!(resolve_route(&rows, mount(), "GET", "").unwrap().0.token, "root"); } #[test] fn a_corrupt_stored_pattern_is_skipped_not_fatal() { - let rows = vec![row("/alice/bad/{", &[], "bad"), row("/alice/good", &[], "good")]; - assert_eq!(resolve_route(&rows, "alice", "GET", "good").unwrap().0.token, "good"); + let rows = vec![at("bad/{", &[], "bad"), at("good", &[], "good")]; + assert_eq!(resolve_route(&rows, mount(), "GET", "good").unwrap().0.token, "good"); + } + + /// Two projects serving the same path each answer at their own + /// address: the project the path names is the only one matched. + #[test] + fn a_call_reaches_only_the_project_its_address_names() { + let other = uuid::Uuid::from_u128(8); + let held = |project: uuid::Uuid, token: &str, binary: &str| HeldRoute { + project_id: project, + row: row(&weft_core::route::SharedMount::new("alice", project).mount_path("chat"), &[], token), + binary_hash: binary.into(), + }; + let routes = vec![held(P, "mine", "bin-p"), held(other, "theirs", "bin-o")]; + assert_eq!(match_relayed(&routes, mount(), "GET", "chat").unwrap(), "bin-p"); + let theirs = weft_core::route::SharedMount::new("alice", other); + assert_eq!(match_relayed(&routes, theirs, "GET", "chat").unwrap(), "bin-o"); + let nobody = weft_core::route::SharedMount::new("alice", uuid::Uuid::from_u128(9)); + assert_eq!(match_relayed(&routes, nobody, "GET", "chat").unwrap_err().0, StatusCode::NOT_FOUND); } } diff --git a/crates/weft-dispatcher/src/app.rs b/crates/weft-dispatcher/src/app.rs index 6f4da5f9..72136f1f 100644 --- a/crates/weft-dispatcher/src/app.rs +++ b/crates/weft-dispatcher/src/app.rs @@ -91,6 +91,9 @@ pub static ALL_GROUPS: &[&weft_task_store::SchemaGroup] = &[ // When each loop of a role that scales to zero next wants a look // (`weft_task_store::drain`); empty on a local install. &weft_task_store::drain::GROUP, + // weft's own calls that keep failing (a role it cannot wake, workers + // it cannot hand a run to), for `weft status` to show. + &weft_task_store::unanswered::GROUP, &RETIRED_DISPATCHER_CURSOR, &RETIRED_INFRA_EVENT_BRIDGE_CURSOR, ]; diff --git a/crates/weft-dispatcher/src/delivery.rs b/crates/weft-dispatcher/src/delivery.rs index 59df8622..d2d63f36 100644 --- a/crates/weft-dispatcher/src/delivery.rs +++ b/crates/weft-dispatcher/src/delivery.rs @@ -18,6 +18,7 @@ use std::time::Duration; +use weft_core::net::EmptyBody; use weft_core::ExecutionId; use weft_task_store::drain::{DrainLoop, DrainStep, WakeOn}; @@ -169,13 +170,20 @@ async fn deliver(state: &DispatcherState, delivery: Delivery) { reason = %format!("{e:#}"), "the run waits for its worker to start; it is handed out again on the next pass" ), - GiveBack::CouldNotHand => tracing::warn!( - target: "weft_dispatcher::delivery", - %execution_id, - project = %delivery.project_id, - error = %format!("{e:#}"), - "could not hand the run to a worker; it is handed out again on the next pass" - ), + GiveBack::CouldNotHand => { + let error = format!("{e:#}"); + tracing::warn!( + target: "weft_dispatcher::delivery", + %execution_id, + project = %delivery.project_id, + %error, + "could not hand the run to a worker; it is handed out again on the next pass" + ); + let callee = weft_task_store::unanswered::Callee::Workers(delivery.project_id); + if let Err(e) = weft_task_store::unanswered::failed(&state.pg_pool, callee, &error).await { + tracing::warn!(target: "weft_dispatcher::delivery", %execution_id, error = %format!("{e:#}"), "could not write down a hand-off that failed"); + } + } } if let Err(e) = give_back(&state.pg_pool, execution_id).await { tracing::warn!(target: "weft_dispatcher::delivery", %execution_id, error = %e, "could not give the delivery back; it is made again once its lease runs out"); @@ -215,6 +223,7 @@ async fn run_on_worker( .http .post(format!("{}/_weft/run/{execution_id}", endpoint.base_url.trim_end_matches('/'))) .header(weft_platform_traits::WORKER_AUTH_HEADER, endpoint.auth_value()) + .empty_body() .send() .await .map_err(|e| { diff --git a/crates/weft-dispatcher/src/door.rs b/crates/weft-dispatcher/src/door.rs index 6416ceb7..0094f293 100644 --- a/crates/weft-dispatcher/src/door.rs +++ b/crates/weft-dispatcher/src/door.rs @@ -98,9 +98,10 @@ fn without_port(host: &str) -> &str { const FILE_LINKS: &str = "/public/files/"; /// Where the path of an API domain's request goes on the install: one of -/// the project's routes, passed on through the relay under its tenant. -pub fn api_path(tenant: &str, path_and_query: &str) -> String { - format!("/connect/{tenant}/{}", path_and_query.trim_start_matches('/')) +/// the project's routes, passed on through the relay under its tenant and +/// project. +pub fn api_path(tenant: &str, project: uuid::Uuid, path_and_query: &str) -> String { + format!("/connect{}/{}", weft_core::route::SharedMount::new(tenant, project).prefix(), path_and_query.trim_start_matches('/')) } #[derive(Clone)] @@ -169,7 +170,7 @@ async fn route(State(door): State, mut request: Request) -> Response { Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("the domain's project: {e:#}")).into_response(), }; let path_and_query = request.uri().path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| "/".into()); - let Ok(uri) = api_path(&tenant, &path_and_query).parse::() else { + let Ok(uri) = api_path(&tenant, project, &path_and_query).parse::() else { return (StatusCode::BAD_REQUEST, "the request's path is not a path").into_response(); }; *request.uri_mut() = uri; @@ -242,8 +243,9 @@ mod tests { #[test] fn an_api_domain_serves_the_projects_routes_at_its_root() { - assert_eq!(api_path("local", "/users/42?x=1"), "/connect/local/users/42?x=1"); - assert_eq!(api_path("local", "/"), "/connect/local/"); - assert_eq!(api_path("local", "/chat?a=1&wct=t"), "/connect/local/chat?a=1&wct=t"); + let p = uuid::Uuid::from_u128(7); + assert_eq!(api_path("local", p, "/users/42?x=1"), format!("/connect/local/{p}/users/42?x=1")); + assert_eq!(api_path("local", p, "/"), format!("/connect/local/{p}/")); + assert_eq!(api_path("local", p, "/chat?a=1&wct=t"), format!("/connect/local/{p}/chat?a=1&wct=t")); } } diff --git a/crates/weft-dispatcher/src/live_relay.rs b/crates/weft-dispatcher/src/live_relay.rs index 7f92a494..0caf7627 100644 --- a/crates/weft-dispatcher/src/live_relay.rs +++ b/crates/weft-dispatcher/src/live_relay.rs @@ -1,12 +1,12 @@ //! Passing a live caller on to the project's workers, from an address the //! install shares between its projects: the loopback port, the tunnel's -//! hostname, an install domain (`/connect//...`), or a project's +//! hostname, an install domain (`/connect///...`), or a project's //! API domain served at its root. //! //! A caller who reaches the project's own address talks to its workers //! straight. One who reaches a shared address is passed on here, and this -//! is a relay only: the tenant's routes (held in memory) pick the project -//! and the program serving the route, and the request goes to that +//! is a relay only: the address names the project, its routes (held in +//! memory) pick the program serving the route, and the request goes to that //! project's workers as the caller sent it. Every check (whether the route //! takes calls, its gate, the instance, the limits) is the worker's door's //! (`weft_engine::door`); nothing of the call is read or written here. @@ -25,7 +25,7 @@ use crate::state::DispatcherState; /// Pass `request` to a worker of `project` running `binary_hash`, at /// `path` (with its leading slash) and `query`, both still percent-encoded /// as the caller sent them. `prefix` is what the route sits under at the -/// address the caller used (`/connect/`, or empty at an API +/// address the caller used (`/connect//`, or empty at an API /// domain's root). pub(crate) async fn to_project( state: &DispatcherState, diff --git a/crates/weft-dispatcher/src/task_kinds/register_signal.rs b/crates/weft-dispatcher/src/task_kinds/register_signal.rs index fdbd78be..fcc4c011 100644 --- a/crates/weft-dispatcher/src/task_kinds/register_signal.rs +++ b/crates/weft-dispatcher/src/task_kinds/register_signal.rs @@ -55,30 +55,22 @@ pub struct RegisterSignalPayload { pub struct RegisterSignalExecutor; -/// The stored mount path for a public-entry surface, namespaced by the owning -/// tenant: `//`. The tenant prefix walls each account into -/// its own path space, so two tenants can both claim `chat` without -/// colliding, and one tenant claiming a path never blocks another (the old -/// global unique index did both wrong). Callers reach it at -/// `/connect//` (live) or `POST //` (public -/// fire), the tenant segment is in the URL. A guessable URL is fine here: -/// live/public endpoints are API surfaces whose callers bring their own -/// auth; the tenant prefix is for COLLISION, not secrecy. The path is a -/// route PATTERN (`chat/{room}`), stored as written; the dispatcher matches -/// calls against it in Rust. +/// The stored mount path for a public-entry surface: its pattern under the +/// project on the install's shared address (`///`, +/// [`weft_core::route::SharedMount`]), so each project has its own path +/// space and two projects can both serve `chat`. Callers reach it at +/// `/connect///` (live) or `POST ///` (public fire). A guessable URL is fine here: live and public +/// endpoints are API surfaces whose callers bring their own auth; the +/// prefix is for collision, not secrecy. The path is a route PATTERN +/// (`chat/{room}`), stored as written; the dispatcher matches calls against +/// it in Rust. fn mount_path_for( surface: &weft_core::primitive::SignalSurface, - tenant: &str, + mount: weft_core::route::SharedMount<'_>, ) -> Option { match surface { - weft_core::primitive::SignalSurface::PublicEntry { path, .. } => { - let path = path.trim_start_matches('/'); - Some(if path.is_empty() { - format!("/{tenant}") - } else { - format!("/{tenant}/{path}") - }) - } + weft_core::primitive::SignalSurface::PublicEntry { path, .. } => Some(mount.mount_path(path)), weft_core::primitive::SignalSurface::TaskCallback | weft_core::primitive::SignalSurface::Internal => None, } @@ -98,7 +90,6 @@ fn mount_methods_for(surface: &weft_core::primitive::SignalSurface) -> Vec, - pub project_id: uuid::Uuid, pub node_id: String, } @@ -375,7 +366,7 @@ impl RegisterSignalExecutor { port_snapshot: payload.port_snapshot.clone(), consumer_payload: (!prepared.rendered.is_null()).then_some(prepared.rendered), surface_kind: prepared.routing.surface.kind_tag().to_string(), - mount_path: mount_path_for(&prepared.routing.surface, tenant.as_str()), + mount_path: mount_path_for(&prepared.routing.surface, weft_core::route::SharedMount::new(tenant.as_str(), project_id)), mount_methods: mount_methods_for(&prepared.routing.surface), auth_kind: prepared.routing.auth.kind_tag().to_string(), auth_config: (!prepared.routing.auth_config.is_null()).then_some(prepared.routing.auth_config), @@ -651,53 +642,38 @@ async fn refuse_unarmable_route( ); } - // Route overlap check: another (project, node) of THIS tenant already - // serves a call this route would claim (`chat/{room}` against - // `chat/{x}`, or `chat/general`, on a shared method). Refuse with a - // clear error rather than let the gateway pick one at call time. Same - // (project, node) reclaiming its route on reactivate is fine because - // it reuses the existing token. The rows are already tenant-prefixed, - // so only the caller's own account is in play; another tenant's - // identical pattern has a different prefix and cannot collide. + // Route overlap check: another trigger of THIS project already serves + // a call this route would claim (`chat/{room}` against `chat/{x}`, or + // `chat/general`, on a shared method). Refuse with a clear error rather + // than let the door pick one at call time. The same node reclaiming its + // route on reactivate is fine because it reuses the existing token. + // Each project has its own path space (`SharedMount`), so another + // project's routes are never in play. + let mount = weft_core::route::SharedMount::new(tenant, project_id); let mine = weft_core::route::RoutePattern::parse(path).map_err(anyhow::Error::msg)?; - let others: Vec = sqlx::query_as::<_, (String, Vec, uuid::Uuid, String)>( - "SELECT mount_path, mount_methods, project_id, node_id \ + let others: Vec = sqlx::query_as::<_, (String, Vec, String)>( + "SELECT mount_path, mount_methods, node_id \ FROM signal \ - WHERE tenant_id = $1 AND mount_path IS NOT NULL \ - AND NOT (project_id = $2 AND node_id = $3)", + WHERE project_id = $1 AND mount_path IS NOT NULL AND node_id <> $2", ) - .bind(tenant) .bind(project_id) .bind(place) .fetch_all(&state.pg_pool) .await? .into_iter() - .map(|(mp, ms, project_id, node_id)| RegisteredRoute { - pattern: weft_core::route::pattern_of_mount_path(&mp, tenant), - methods: ms, - project_id, - node_id, - }) + .map(|(mp, ms, node_id)| RegisteredRoute { pattern: mount.pattern_of(&mp), methods: ms, node_id }) .collect(); if let Some(taken) = ambiguous_route(&mine, methods, &others) { let method_words = |m: &[String]| if m.is_empty() { "any method".to_string() } else { m.join("/") }; anyhow::bail!( - "route `{}` ({}) and `{}` ({}), already registered by \ - project='{}' node='{}', can both be reached by one \ - call and neither is the more specific, so that call \ - has no answer. Both projects are yours, so: make one \ - of them spell out what the other captures, change \ - this route's `path` or `method`, or free the other \ - with `weft deactivate --project {}` (a project you \ - are done with can also go entirely, `weft rm {}`)", + "route `{}` ({}) and `{}` ({}) of trigger '{}' can both be reached by one call and \ + neither is the more specific, so that call has no answer. Make one of them spell out \ + what the other captures, or change this route's `path` or `method`", mine.as_str(), method_words(methods), taken.pattern, method_words(&taken.methods), - taken.project_id, taken.node_id, - taken.project_id, - taken.project_id, ); } Ok(()) @@ -790,32 +766,33 @@ mod tests { SignalSurface::PublicEntry { path: path.into(), methods: Vec::new() } } - #[test] - fn public_entry_path_is_tenant_namespaced() { - let s = entry("chat"); - assert_eq!(mount_path_for(&s, "alice").as_deref(), Some("/alice/chat")); - // Same path, different tenant -> different mount path (no collision). - assert_eq!(mount_path_for(&s, "bob").as_deref(), Some("/bob/chat")); - } + const ONE: uuid::Uuid = uuid::Uuid::from_u128(1); + const TWO: uuid::Uuid = uuid::Uuid::from_u128(2); - #[test] - fn public_entry_empty_path_is_just_the_tenant() { - assert_eq!(mount_path_for(&entry(""), "alice").as_deref(), Some("/alice")); + fn under(tenant: &str, project: uuid::Uuid) -> weft_core::route::SharedMount<'_> { + weft_core::route::SharedMount::new(tenant, project) } #[test] - fn leading_slash_in_path_is_normalized() { - let s = entry("/webhooks/stripe"); - assert_eq!(mount_path_for(&s, "acme").as_deref(), Some("/acme/webhooks/stripe")); + fn a_public_entry_is_stored_under_its_tenant_and_project() { + let s = entry("chat"); + assert_eq!(mount_path_for(&s, under("alice", ONE)), Some(format!("/alice/{ONE}/chat"))); + // The same path in another project or another tenant is another + // mount path, so neither blocks the other. + assert_eq!(mount_path_for(&s, under("alice", TWO)), Some(format!("/alice/{TWO}/chat"))); + assert_eq!(mount_path_for(&s, under("bob", ONE)), Some(format!("/bob/{ONE}/chat"))); + // The project's root, and a leading slash written in the path. + assert_eq!(mount_path_for(&entry(""), under("alice", ONE)), Some(format!("/alice/{ONE}"))); + assert_eq!(mount_path_for(&entry("/webhooks/stripe"), under("acme", ONE)), Some(format!("/acme/{ONE}/webhooks/stripe"))); } #[test] - fn a_pattern_is_stored_as_written_and_read_back_without_the_tenant() { + fn a_pattern_is_stored_as_written_and_read_back_without_the_prefix() { let s = SignalSurface::PublicEntry { path: "chat/{room}".into(), methods: vec!["POST".into()] }; - let stored = mount_path_for(&s, "alice").unwrap(); - assert_eq!(stored, "/alice/chat/{room}"); - assert_eq!(weft_core::route::pattern_of_mount_path(&stored, "alice"), "chat/{room}"); - assert_eq!(weft_core::route::pattern_of_mount_path("/alice", "alice"), ""); + let stored = mount_path_for(&s, under("alice", ONE)).unwrap(); + assert_eq!(stored, format!("/alice/{ONE}/chat/{{room}}")); + assert_eq!(under("alice", ONE).pattern_of(&stored), "chat/{room}"); + assert_eq!(under("alice", ONE).pattern_of(&format!("/alice/{ONE}")), ""); assert_eq!(mount_methods_for(&s), vec!["POST".to_string()]); assert!(mount_methods_for(&SignalSurface::TaskCallback).is_empty()); } @@ -824,7 +801,6 @@ mod tests { RegisteredRoute { pattern: pattern.into(), methods: methods.iter().map(|m| m.to_string()).collect(), - project_id: uuid::Uuid::from_u128(0x106), node_id: "other".into(), } } @@ -1085,7 +1061,7 @@ mod tests { #[test] fn non_public_surfaces_have_no_mount_path() { - assert!(mount_path_for(&SignalSurface::TaskCallback, "alice").is_none()); - assert!(mount_path_for(&SignalSurface::Internal, "alice").is_none()); + assert!(mount_path_for(&SignalSurface::TaskCallback, under("alice", ONE)).is_none()); + assert!(mount_path_for(&SignalSurface::Internal, under("alice", ONE)).is_none()); } } diff --git a/crates/weft-dispatcher/tests/db_lifecycle.rs b/crates/weft-dispatcher/tests/db_lifecycle.rs index 9100afad..f30fe79b 100644 --- a/crates/weft-dispatcher/tests/db_lifecycle.rs +++ b/crates/weft-dispatcher/tests/db_lifecycle.rs @@ -1906,7 +1906,7 @@ async fn the_held_rows_announce_their_changes(pool: PgPool) { assert!(!heard_on(&mut heard, ROUTES_CHANNEL, TENANT).await, "a signal that is no public entry is silent"); let route = SignalRegistration { surface_kind: "public_entry".into(), - mount_path: Some(format!("/{TENANT}/notes")), + mount_path: Some(format!("/{TENANT}/{id}/notes")), ..entry_at("route", id) }; journal.signal_insert(&route).await.unwrap(); diff --git a/crates/weft-e2e/src/live.rs b/crates/weft-e2e/src/live.rs index d82cd017..770f569e 100644 --- a/crates/weft-e2e/src/live.rs +++ b/crates/weft-e2e/src/live.rs @@ -1,7 +1,7 @@ //! The live-caller path: an outside party holds an HTTP stream or a two-way //! WebSocket against a running program. //! -//! A call is `/connect//{path}` on the install's shared address, +//! A call is `/connect///{path}` on the install's shared address, //! any method, and is answered in that one request: the install passes it //! on to the project's workers, whose door checks the caller and starts the //! run. A socket is opened there the same way, by a client that sends the diff --git a/crates/weft-e2e/src/project.rs b/crates/weft-e2e/src/project.rs index 55902ab3..d324efd8 100644 --- a/crates/weft-e2e/src/project.rs +++ b/crates/weft-e2e/src/project.rs @@ -327,11 +327,12 @@ impl Project { /// Substitute the live-trigger mount-path placeholder `__E2E_PATH__` with a /// per-project-unique path, and return the CALLABLE path the test connects - /// to. Mount paths are namespaced per tenant on the dispatcher, so the - /// stored path (and the callable URL) is `//`: the node config - /// gets the bare ``, but a caller reaches it at `/connect//` - /// (live) or `POST //` (public fire). e2e runs as tenant - /// `local`, so the callable path is `local/`. The unique suffix is + /// to. Mount paths sit under the tenant and the project on the install's + /// shared address, so the stored path (and the callable URL) is + /// `///`: the node config gets the bare ``, + /// but a caller reaches it at `/connect///` (live) + /// or `POST ///` (public fire). e2e runs as tenant + /// `local`, so the callable path is `local//`. The unique suffix is /// derived from the project's fresh id (stable within a run, distinct across /// runs). Call BEFORE activate. pub fn unique_live_path(&self) -> Result { @@ -358,10 +359,10 @@ impl Project { /// and answer the callable path. pub fn mount_at(&self, path: &str) -> Result { // The node config carries the BARE path; the dispatcher prefixes the - // owning tenant when it stores + serves the mount path. + // owning tenant and project when it stores + serves the mount path. self.substitute_in_sources("__E2E_PATH__", path)?; - // The test connects at the tenant-namespaced path (e2e tenant = local). - Ok(format!("local/{path}")) + // The test connects under the tenant (e2e tenant = local) and project. + Ok(format!("local/{}/{path}", self.id)) } /// Build the project's worker image and register it (the real diff --git a/crates/weft-e2e/tests/api_auth.rs b/crates/weft-e2e/tests/api_auth.rs index 8226e37f..6dd6b1bf 100644 --- a/crates/weft-e2e/tests/api_auth.rs +++ b/crates/weft-e2e/tests/api_auth.rs @@ -125,52 +125,35 @@ async fn api_keys_and_a_signing_secret_gate_their_routes() -> anyhow::Result<()> secret.finish().await } -/// Two PROJECTS of one account claiming one address. Each file is fine -/// on its own, so the compiler cannot see this pair; only the -/// dispatcher, which holds every project's claims in one table, can. -/// The second activation is refused naming the overlap, before any -/// route of it is armed, and the first keeps serving. +/// Two PROJECTS of one account serving the same path. Each project's +/// routes sit under its own id on the install's shared address, so both +/// activate and each call reaches the project its address names. /// -/// The half inside ONE file is answered earlier, by the compiler +/// Two routes of ONE file colliding is answered earlier, by the compiler /// (`route-overlap`), so a program that collides with itself never gets /// this far. #[tokio::test] -async fn overlapping_routes_are_refused_at_activation() -> anyhow::Result<()> { +async fn two_projects_serve_the_same_path_each_at_its_own_address() -> anyhow::Result<()> { let disp = ensure::up().await?; let mut first = Project::prepare("api_overlap", disp.clone()).await?; // The bare path is what goes in the file; the callable one carries - // the tenant the dispatcher serves it under, and is what a caller - // dials. + // the tenant and project the dispatcher serves it under, and is what + // a caller dials. let path = first.bare_live_path(); - let base = first.mount_at(&path)?; + let first_base = first.mount_at(&path)?; first.activate().await?; - // The first project's route answers before the collision, so a - // failure after it cannot be blamed on the route never having - // worked. - let call = format!("{base}/chat/anything"); - let (status, _, _) = - live::http_json(&disp, Method::POST, &call, &[], &serde_json::json!({})).await?; - anyhow::ensure!(status.is_success(), "the first project serves its route: {status}"); - - // The same address, from a second project of the same account. + // The same path, from a second project of the same account. let mut second = Project::prepare("api_overlap", disp.clone()).await?; - second.mount_at(&path)?; - let out = second.activate_refused().await?; - anyhow::ensure!( - out.contains("neither is the more specific"), - "the refusal says why the pair cannot stand: {out}" - ); + let second_base = second.mount_at(&path)?; + second.activate().await?; + anyhow::ensure!(first_base != second_base, "each project is called at its own address"); - // The half the comment used to promise and never checked: a refused - // activation must not have armed anything, so the project that was - // already there keeps answering exactly as before. - let (status, _, _) = - live::http_json(&disp, Method::POST, &call, &[], &serde_json::json!({})).await?; - anyhow::ensure!( - status.is_success(), - "the refused second activation left the first project serving: {status}" - ); + for (base, project) in [(&first_base, &first), (&second_base, &second)] { + let call = format!("{base}/chat/anything"); + let (status, _, _) = live::http_json(&disp, Method::POST, &call, &[], &serde_json::json!({})).await?; + anyhow::ensure!(status.is_success(), "{} serves its route at {call}: {status}", project.id()); + } second.finish().await?; first.finish().await diff --git a/crates/weft-e2e/tests/api_reply.rs b/crates/weft-e2e/tests/api_reply.rs index c6e58516..97782166 100644 --- a/crates/weft-e2e/tests/api_reply.rs +++ b/crates/weft-e2e/tests/api_reply.rs @@ -230,7 +230,7 @@ async fn the_project_answers_at_its_own_address() -> anyhow::Result<()> { let status: Value = disp.get_json(&format!("/projects/{}/status", project.id())).await?; anyhow::ensure!(status["address"]["state"] == "serving", "the project's address: {}", status["address"]); let url = status["address"]["url"].as_str().expect("a serving address has a url").to_string(); - let path = base.trim_start_matches("local/"); + let path = base.trim_start_matches(&format!("local/{}/", project.id())); let answered = reqwest::get(format!("{url}/{path}/users/42")).await?; assert_eq!(answered.status(), 200); let body: Value = answered.json().await?; diff --git a/crates/weft-runtime/src/lib.rs b/crates/weft-runtime/src/lib.rs index ea6717a5..360a331c 100644 --- a/crates/weft-runtime/src/lib.rs +++ b/crates/weft-runtime/src/lib.rs @@ -137,7 +137,10 @@ pub async fn serve(config: InstallConfig, only: Option) -> anyhow::Res let parts = platform::build(&config, pool.as_ref().map(|(_, p)| p)).await?; // A writer that is up wakes the roles at zero its writes concern. let waker = match writes { - true => role_waker::RoleWaker::new(&config, &addresses, parts.tokens.clone())?.map(Arc::new), + true => { + let (_, pool) = pool.as_ref().expect("a process that writes holds the database"); + role_waker::RoleWaker::new(&config, &addresses, parts.tokens.clone(), pool.clone())?.map(Arc::new) + } false => None, }; diff --git a/crates/weft-runtime/src/role_waker.rs b/crates/weft-runtime/src/role_waker.rs index 22cad11b..eedb4c24 100644 --- a/crates/weft-runtime/src/role_waker.rs +++ b/crates/weft-runtime/src/role_waker.rs @@ -19,7 +19,9 @@ //! wakes (writes made before it listened were heard by nobody), which //! starts each role's own chain of wakes (every tick books the next one), //! and again whenever notifications may have been lost. A ring that fails -//! is rung again, with a growing pause, until the role answers. A process +//! is rung again, with a growing pause, until the role answers; each +//! failure is kept in the database (`weft_task_store::unanswered`), so +//! `weft status` can say why the work waiting on that role does not move. A process //! that is stopping waits a few seconds for the rings still out, and logs //! the ones it gives up on. //! @@ -34,11 +36,13 @@ use std::time::Duration; use parking_lot::Mutex; use weft_broker_client::lifecycle_command::{ISSUED_WAKE, LOOK_WAKE}; +use weft_core::net::EmptyBody; use weft_platform_traits::config::InstallConfig; use weft_platform_traits::roles::TICK_PATH; use weft_platform_traits::{CoreRole, IdentityTokens, Placement, RoleAddresses}; use weft_task_store::drain::WakeOn; use weft_task_store::pg_signal::{Heard, Subscription}; +use weft_task_store::unanswered::{self, Callee}; use crate::server::TICK_LOOP_PARAM; @@ -92,6 +96,8 @@ struct Bell { pub struct RoleWaker { bells: BTreeMap>, tokens: Arc, + /// Where a ring that keeps failing is written down. + pool: sqlx::PgPool, http: reqwest::Client, /// Told whenever a ring is answered and none is left for its role, so /// a stopping process can wait for the rings still out ([`Self::settle`]). @@ -101,7 +107,12 @@ pub struct RoleWaker { impl RoleWaker { /// A bell for every serverless role with loops to wake, at its /// address as this process reaches it. `None` when none sleeps. - pub fn new(config: &InstallConfig, addresses: &RoleAddresses, tokens: Arc) -> anyhow::Result> { + pub fn new( + config: &InstallConfig, + addresses: &RoleAddresses, + tokens: Arc, + pool: sqlx::PgPool, + ) -> anyhow::Result> { let mut bells = BTreeMap::new(); // The listener has no loops to drain, so no tick to ring; the // holder is never serverless. @@ -116,7 +127,7 @@ impl RoleWaker { if bells.is_empty() { return Ok(None); } - Ok(Some(Self { bells, tokens, http: reqwest::Client::new(), answered: Arc::default() })) + Ok(Some(Self { bells, tokens, pool, http: reqwest::Client::new(), answered: Arc::default() })) } /// Every channel a sleeping role's loops wait on: what this process's @@ -191,22 +202,31 @@ impl RoleWaker { } ring.ringing = true; } - let (tokens, http, answered) = (self.tokens.clone(), self.http.clone(), self.answered.clone()); + let (tokens, http, answered, pool) = (self.tokens.clone(), self.http.clone(), self.answered.clone(), self.pool.clone()); tokio::spawn(async move { let mut woken = woken; let mut pause = Duration::from_secs(1); + let callee = Callee::Role(bell.role.as_str()); + // Whether this ring's last try failed, so its first answer + // clears what it wrote down. + let mut failing = false; loop { let sent = async { let token = tokens.token_for(&bell.audience).await?; let query: Vec<(&str, &str)> = woken.iter().map(|name| (TICK_LOOP_PARAM, name.as_str())).collect(); - http.post(&bell.url).query(&query).bearer_auth(token).send().await?.error_for_status()?; + http.post(&bell.url).query(&query).bearer_auth(token).empty_body().send().await?.error_for_status()?; anyhow::Ok(()) }; if let Err(e) = sent.await { + let error = format!("{e:#}"); tracing::warn!( - target: "weft_runtime::role_waker", role = %bell.role, error = %format!("{e:#}"), + target: "weft_runtime::role_waker", role = %bell.role, %error, retry_in_secs = pause.as_secs(), "could not wake a role; ringing again" ); + failing = true; + if let Err(e) = unanswered::failed(&pool, callee, &error).await { + tracing::warn!(target: "weft_runtime::role_waker", role = %bell.role, error = %format!("{e:#}"), "could not write down a ring that failed"); + } tokio::time::sleep(pause).await; pause = (pause * 2).min(LONGEST_RETRY); if let Some(more) = bell.ring.lock().pending.take() { @@ -215,6 +235,11 @@ impl RoleWaker { continue; } pause = Duration::from_secs(1); + if std::mem::take(&mut failing) { + if let Err(e) = unanswered::answered(&pool, callee).await { + tracing::warn!(target: "weft_runtime::role_waker", role = %bell.role, error = %format!("{e:#}"), "could not clear a ring that failed before"); + } + } let mut ring = bell.ring.lock(); match ring.pending.take() { Some(more) => woken = more, diff --git a/crates/weft-task-store/migrations/exec_event/20261008T155350_exec_event_routes_sit_under_their_project.sql b/crates/weft-task-store/migrations/exec_event/20261008T155350_exec_event_routes_sit_under_their_project.sql new file mode 100644 index 00000000..b96ba80b --- /dev/null +++ b/crates/weft-task-store/migrations/exec_event/20261008T155350_exec_event_routes_sit_under_their_project.sql @@ -0,0 +1,8 @@ +-- Written by hand (a route's stored address gained the project's id after +-- the tenant, which changes values in rows and no generated migration +-- sees): `//` becomes `///`, so +-- each project has its own path space on the install's shared address. +UPDATE signal +SET mount_path = '/' || tenant_id || '/' || project_id::text || substr(mount_path, length(tenant_id) + 2) +WHERE mount_path IS NOT NULL + AND mount_path NOT LIKE '/' || tenant_id || '/' || project_id::text || '%'; diff --git a/crates/weft-task-store/migrations/unanswered_call/origin.sql b/crates/weft-task-store/migrations/unanswered_call/origin.sql new file mode 100644 index 00000000..46898f54 --- /dev/null +++ b/crates/weft-task-store/migrations/unanswered_call/origin.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS unanswered_call ( + -- What does not answer (`Callee::key`). + callee TEXT PRIMARY KEY, + -- The last try's error, as the caller logged it. + error TEXT NOT NULL, + -- The first failure of this stretch and the last, in unix + -- milliseconds on the database's clock. + since_ms BIGINT NOT NULL, + last_ms BIGINT NOT NULL + ); diff --git a/crates/weft-task-store/src/lib.rs b/crates/weft-task-store/src/lib.rs index e6ec68c4..be7d20ef 100644 --- a/crates/weft-task-store/src/lib.rs +++ b/crates/weft-task-store/src/lib.rs @@ -20,6 +20,8 @@ //! waiter built on it). //! - `announce`: how a write announces itself without every commit //! waiting on every other (an outbox, flushed in batches). +//! - `unanswered`: weft's own calls that keep failing, kept for +//! whoever waits on the work behind them to see why. //! - `held_copy`: a process's copy of rows read on every request, //! dropped the moment a notification says they changed. //! - `schema_guard`: the schema runner every boot routes its @@ -43,6 +45,7 @@ pub mod schema_guard; pub mod tasks; pub mod terminal; pub mod traits; +pub mod unanswered; pub mod worker_door; pub use schema_guard::{apply_groups, Migration, SchemaGroup}; diff --git a/crates/weft-task-store/src/unanswered.rs b/crates/weft-task-store/src/unanswered.rs new file mode 100644 index 00000000..3b6679e3 --- /dev/null +++ b/crates/weft-task-store/src/unanswered.rs @@ -0,0 +1,113 @@ +//! The `unanswered_call` table: weft's own calls that keep failing. +//! +//! When weft cannot reach one of its own pieces (a role it rings awake, a +//! project's workers it hands a run to), it tries again on its own, and the +//! work behind the call waits meanwhile: an infra start nobody picks up, a +//! run nobody claims. The process that tries is not the one a person asks, +//! so each failure is written here, and `weft status` shows the ones still +//! failing, with the error, for whoever is waiting to see why. +//! +//! A row is written on every failed try. A role's ring is one call tried +//! until it goes through, which then drops the row. A run's hand-off is a +//! new call per run, and dropping the row on every one that goes through +//! would cost every run a write, so a row is read as current only while +//! its last failure is [`RECENT_MS`] old at most: a call still failing is +//! tried again well within that, and one that recovered stops being shown +//! once it has passed. + +use anyhow::Result; +use sqlx::postgres::PgPool; + +use crate::drain::DB_NOW_MS; + +pub static GROUP: crate::SchemaGroup = crate::SchemaGroup { + name: "unanswered_call", + tables: &["unanswered_call"], + ddl: &[r#"CREATE TABLE IF NOT EXISTS unanswered_call ( + -- What does not answer (`Callee::key`). + callee TEXT PRIMARY KEY, + -- The last try's error, as the caller logged it. + error TEXT NOT NULL, + -- The first failure of this stretch and the last, in unix + -- milliseconds on the database's clock. + since_ms BIGINT NOT NULL, + last_ms BIGINT NOT NULL + )"#], + seed: &[], +}; + +/// How old a row's last failure may be and still be read as failing now. A +/// role's ring is tried again a minute after a failure at most, and a run's +/// hand-off at the delivery's next look, which is sooner. +pub const RECENT_MS: i64 = 120_000; + +/// What weft could not reach. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Callee<'a> { + /// A role it rings awake, by `CoreRole::as_str`. + Role(&'a str), + /// A project's workers, a run being handed to them. + Workers(uuid::Uuid), +} + +impl Callee<'_> { + fn key(self) -> String { + match self { + Callee::Role(role) => format!("role:{role}"), + Callee::Workers(project) => format!("workers:{project}"), + } + } +} + +/// A call still failing, as read back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Failing { + /// The role's name, or `None` for the project's workers. + pub role: Option, + pub error: String, + pub since_ms: i64, + pub last_ms: i64, +} + +/// A try at `callee` failed with `error`. +pub async fn failed(pool: &PgPool, callee: Callee<'_>, error: &str) -> Result<()> { + sqlx::query(&format!( + "INSERT INTO unanswered_call (callee, error, since_ms, last_ms) VALUES ($1, $2, {DB_NOW_MS}, {DB_NOW_MS}) \ + ON CONFLICT (callee) DO UPDATE SET error = EXCLUDED.error, last_ms = EXCLUDED.last_ms, \ + since_ms = CASE WHEN unanswered_call.last_ms < EXCLUDED.last_ms - {RECENT_MS} \ + THEN EXCLUDED.since_ms ELSE unanswered_call.since_ms END" + )) + .bind(callee.key()) + .bind(error) + .execute(pool) + .await?; + Ok(()) +} + +/// A try at `callee` went through after this caller's failed. +pub async fn answered(pool: &PgPool, callee: Callee<'_>) -> Result<()> { + sqlx::query("DELETE FROM unanswered_call WHERE callee = $1").bind(callee.key()).execute(pool).await?; + Ok(()) +} + +/// Every role still failing, and `project`'s workers if they are: what +/// the project's work waits on. +pub async fn failing_for(pool: &PgPool, project: uuid::Uuid) -> Result> { + let rows: Vec<(String, String, i64, i64)> = sqlx::query_as(&format!( + "SELECT callee, error, since_ms, last_ms FROM unanswered_call \ + WHERE (callee LIKE 'role:%' OR callee = $1) AND last_ms >= {DB_NOW_MS} - {RECENT_MS} \ + ORDER BY callee" + )) + .bind(Callee::Workers(project).key()) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(callee, error, since_ms, last_ms)| Failing { + role: callee.strip_prefix("role:").map(str::to_string), + error, + since_ms, + last_ms, + }) + .collect()) +} diff --git a/crates/weft-task-store/tests/unanswered.rs b/crates/weft-task-store/tests/unanswered.rs new file mode 100644 index 00000000..ef3636c3 --- /dev/null +++ b/crates/weft-task-store/tests/unanswered.rs @@ -0,0 +1,41 @@ +//! weft's own calls that keep failing, against a real Postgres: what a +//! project's status is shown is what the SQL keeps and reads back. +//! +//! Gated behind `db-tests`; `scripts/run-db-tests.sh weft-task-store` +//! runs them. +#![cfg(feature = "db-tests")] + +use sqlx::PgPool; +use uuid::Uuid; + +use weft_task_store::unanswered::{self, Callee}; + +/// A failing role shows for every project, a project's workers only for +/// that project; a second failure keeps when the stretch began and takes +/// the newest error; an answer clears it; a row whose last failure is old +/// is no longer shown. +#[sqlx::test] +async fn a_call_that_keeps_failing_is_shown_to_the_projects_it_holds_up(pool: PgPool) { + weft_task_store::apply_groups(&pool, &[&unanswered::GROUP]).await.expect("schema"); + let (mine, theirs) = (Uuid::from_u128(1), Uuid::from_u128(2)); + + unanswered::failed(&pool, Callee::Role("supervisor"), "411 Length Required").await.unwrap(); + unanswered::failed(&pool, Callee::Workers(theirs), "connection refused").await.unwrap(); + let shown = unanswered::failing_for(&pool, mine).await.unwrap(); + assert_eq!(shown.len(), 1, "another project's workers are not mine: {shown:?}"); + assert_eq!(shown[0].role.as_deref(), Some("supervisor")); + + let workers_of = async |project| unanswered::failing_for(&pool, project).await.unwrap().into_iter().find(|f| f.role.is_none()); + unanswered::failed(&pool, Callee::Workers(mine), "first").await.unwrap(); + let since = workers_of(mine).await.unwrap().since_ms; + unanswered::failed(&pool, Callee::Workers(mine), "second").await.unwrap(); + let workers = workers_of(mine).await.unwrap(); + assert_eq!((workers.error.as_str(), workers.since_ms), ("second", since), "the stretch keeps its start"); + + unanswered::answered(&pool, Callee::Role("supervisor")).await.unwrap(); + let shown = unanswered::failing_for(&pool, mine).await.unwrap(); + assert!(shown.iter().all(|f| f.role.is_none()), "an answered ring is cleared: {shown:?}"); + + sqlx::query("UPDATE unanswered_call SET last_ms = last_ms - $1 - 1").bind(unanswered::RECENT_MS).execute(&pool).await.unwrap(); + assert!(unanswered::failing_for(&pool, mine).await.unwrap().is_empty(), "an old failure is not shown"); +} diff --git a/deploy/terraform/gcp/machines.tf b/deploy/terraform/gcp/machines.tf index ceab73e1..7ab1e870 100644 --- a/deploy/terraform/gcp/machines.tf +++ b/deploy/terraform/gcp/machines.tf @@ -24,6 +24,20 @@ resource "google_logging_project_sink" "machine_events" { protoPayload.methodName=("compute.instances.preempted" OR "compute.instances.hostError" OR "compute.instances.guestTerminate" OR "compute.instances.automaticRestart" OR "v1.compute.instances.stop" OR "v1.compute.instances.suspend" OR "v1.compute.instances.delete" OR "v1.compute.instances.reset") EOT unique_writer_identity = true + depends_on = [google_workload_identity_service_agent.logging] +} + +# The sink writes as Cloud Logging's service agent, which Google makes on +# its own only some time after the API is first used: on a new project the +# grant to the sink's writer can name an account that does not exist yet. +# So the agent is asked for before the sink, the way Google's docs say to +# for infrastructure as code. All it needs here is to publish to the topic, +# granted below. Another install in the same project shares the agent, so +# a destroy leaves it. +resource "google_workload_identity_service_agent" "logging" { + parent = "projects/${data.google_project.this.number}/locations/global/serviceProducers/logging.googleapis.com" + deletion_policy = "ABANDON" + depends_on = [google_project_service.apis] } resource "google_pubsub_topic_iam_member" "machine_events_published_by_the_sink" { diff --git a/deploy/terraform/gcp/versions.tf b/deploy/terraform/gcp/versions.tf index 53b1ea9b..8e721733 100644 --- a/deploy/terraform/gcp/versions.tf +++ b/deploy/terraform/gcp/versions.tf @@ -9,8 +9,9 @@ terraform { required_providers { google = { source = "hashicorp/google" - # Cloud Run worker pools (the holders) arrived in 7. - version = "~> 7.12" + # Cloud Run worker pools (the holders) arrived in 7, and + # `google_workload_identity_service_agent` (machines.tf) in 7.28. + version = "~> 7.28" } random = { source = "hashicorp/random" @@ -48,6 +49,8 @@ resource "google_project_service" "apis" { "run.googleapis.com", "secretmanager.googleapis.com", "storage.googleapis.com", + # making Cloud Logging's service agent up front (machines.tf) + "workloadidentity.googleapis.com", ]) service = each.value disable_on_destroy = false diff --git a/docs/src/build/public-address.md b/docs/src/build/public-address.md index ef256ada..06f374f7 100644 --- a/docs/src/build/public-address.md +++ b/docs/src/build/public-address.md @@ -32,11 +32,11 @@ These paths, and nothing else. Everything else on that address answers 404. | `POST /signal-token/displays///action` | That client pressing a button on it | | `/instance/...` | Somebody holding an instance token for one of your programs: that instance's fields, its connections, its runs | | `GET /public/files/` | Somebody fetching a file link your program handed out | -| `/connect//` | A caller of one of your routes or sockets | +| `/connect///` | A caller of one of your routes or sockets | | `/infra///...` | A caller of an infrastructure endpoint your node opened to the public | | `GET /access/oauth/callback` | A provider sending you back after you approve a connection | | `/access/picker/` | The page where you pick files for a connection, and its answer | -| `POST /` | A trigger of yours that fires on a plain POST to its own path | +| `POST ///` | A trigger of yours that fires on a plain POST to its own path | | `/`, `/index.html`, `/logo.png` | A small page saying this address is a weft install | Every `/signal-token/...` request carries the signal token in its diff --git a/docs/src/language/triggers-and-routes.md b/docs/src/language/triggers-and-routes.md index 708a7828..187b1a79 100644 --- a/docs/src/language/triggers-and-routes.md +++ b/docs/src/language/triggers-and-routes.md @@ -69,9 +69,14 @@ specific. Two spellings that genuinely overlap are the `route-overlap` error, because a call arriving would have no defined answer. You know a route's address before you activate: on your machine it is -`http://127.0.0.1:14111/connect/local/cards` (the install's address, then -`/connect/local/`, then the path). On a cloud install it is the install's own -address, then `/connect/local/`, then the path. +`http://127.0.0.1:14111/connect/local//cards` (the install's +address, then `/connect/local/`, then your project's id, then the path). The +project id is the `id` under `[package]` in your `weft.toml`, so it is the +same on every install, and two projects can each serve a `cards` route without +getting in each other's way. On a cloud install it is the install's own +address, then `/connect/local//`, then the path. `weft activate` +prints the id (`activated cards ()`), and the route's trigger +shows its full address in the editor. If you want the fastest way in, or a frontend wants your routes at the root of an address, call the project's own address, where your program answers with @@ -84,13 +89,13 @@ activate --port 8080` opens that one, and the project keeps it for later activates. If another program or another of your projects holds that port, `weft activate` fails and nothing is activated. `weft activate` prints the address, and `weft status` shows it later or says -why it is unavailable. Your routes keep answering under `/connect/local/` as well. +why it is unavailable. Your routes keep answering under `/connect/local//` as well. If a call arrives while the route's trigger is parked, hibernating within its grace window, or still being set up, it is answered `503` with a `Retry-After` at once, so a client that retries gets through once the trigger is on. Once the project is switched off (wiped), its own port is closed and -`/connect/local/` answers `404`. +`/connect/local//` answers `404`. ## How a run is kept diff --git a/docs/src/running/architecture.md b/docs/src/running/architecture.md index 9aa5b375..9311efa9 100644 --- a/docs/src/running/architecture.md +++ b/docs/src/running/architecture.md @@ -76,10 +76,10 @@ address, and `weft status` shows it. For how a browser opens a socket on a route that checks callers, go and read [answering on a socket](../language/triggers-and-routes.md#answering-on-a-socket). -The address an install shares between its projects (`/connect/local/...`, +The address an install shares between its projects (`/connect/local//...`, the tunnel's hostname, the install's own domain) still works: the dispatcher -picks the project and the program by the route, from routes it holds in -memory, and passes the call on. It adds headers giving the caller's address and +picks the project by the id in the address and the program by the route, from +routes it holds in memory, and passes the call on. It adds headers giving the caller's address and the `Host` they sent, and the worker trusts those headers only when the call carries weft's own key. The dispatcher checks nothing else and writes nothing. diff --git a/docs/src/running/cloud.md b/docs/src/running/cloud.md index 02e7159e..0c3d67c5 100644 --- a/docs/src/running/cloud.md +++ b/docs/src/running/cloud.md @@ -262,7 +262,7 @@ every domain with its record. A domain can also serve one project instead of the whole install: `--for api` answers that project's routes at the root of the domain (`https://api.example.com/users/42`, as well as -`https:///connect/local/users/42`), sending its calls +`https:///connect/local//users/42`), sending its calls straight to the project's own Cloud Run service, and `--for frontend --to
` passes visitors on to the project's frontend on Cloud Run, at the address `weft frontend ls --on prod` shows for @@ -474,9 +474,11 @@ frontend builds differently, edit the workflow's "build the frontend" step. ## Routes on the internet -A `Route` answers at `https:///connect/local/`, -where `` is the route's `path`. The `local` in that path is the same on every install, prod included, so a -route's address on prod differs from your machine's only in the host. The +A `Route` answers at `https:///connect/local//`, +where `` is the `id` under `[package]` in your `weft.toml` and +`` is the route's `path`. The `local` and the project id are the same on +every install, prod included, so a route's address on prod differs from your +machine's only in the host. The project also answers at its own Cloud Run address (`weft status --on prod` shows it), where the call reaches your program with nothing of weft's in between: give that one, or a `--for api` domain, to a client that cares how diff --git a/docs/src/running/instances.md b/docs/src/running/instances.md index de9eee0f..b0dd4bc4 100644 --- a/docs/src/running/instances.md +++ b/docs/src/running/instances.md @@ -167,7 +167,7 @@ any instance, so weft refuses the header there and says why. A browser presents an instance token instead. If you call a route and want the run to be for an instance, call it at one of -its route addresses: `/connect/local/` on the install, the project's own +its route addresses: `/connect/local//` on the install, the project's own port on your machine, or an API domain (for where those come from, go and read [answering on a URL](../language/triggers-and-routes.md#answering-on-a-url)). A webhook also answers at a plain address without `/connect`, but a run started diff --git a/extension-browser/package.json b/extension-browser/package.json index e3c943f9..bc471a68 100644 --- a/extension-browser/package.json +++ b/extension-browser/package.json @@ -2,7 +2,7 @@ "name": "weft-extension", "description": "Human-in-the-loop task manager for weft projects", "private": true, - "version": "1.0.77", + "version": "1.0.78", "packageManager": "pnpm@10.32.1", "type": "module", "scripts": { diff --git a/extension-vscode/package.json b/extension-vscode/package.json index 0ebfc63c..427c4a06 100644 --- a/extension-vscode/package.json +++ b/extension-vscode/package.json @@ -2,7 +2,7 @@ "name": "weft-vscode", "displayName": "Weft", "description": "Write Weft programs and watch them run: a live, editable graph of every .weft file, with diagnostics, runs and executions in the editor.", - "version": "0.2.261", + "version": "0.2.262", "publisher": "weavemind", "license": "SEE LICENSE IN LICENSE", "repository": { diff --git a/packages/weft-connect/README.md b/packages/weft-connect/README.md index 4892e3b6..56c97eee 100644 --- a/packages/weft-connect/README.md +++ b/packages/weft-connect/README.md @@ -54,7 +54,7 @@ export const fallback: RequestHandler = ({ request, params }) => pass(request, p It passes on the instance door (`/instance/...`), the signal doors (`/signal/...`), the token's listings, displays and files (`/signal-token/...`), the program's own routes -(`/connect//`), the picture links in their answers +(`/connect///`), the picture links in their answers (`/public/files/`) and a field's file chooser (`/access/picker/` and the result it posts back), and answers 404 for anything else. It carries the caller's `Authorization`, `Weft-Instance-Token`, diff --git a/packages/weft-connect/src/core/socket.test.ts b/packages/weft-connect/src/core/socket.test.ts index 1e5730f5..ea72cba2 100644 --- a/packages/weft-connect/src/core/socket.test.ts +++ b/packages/weft-connect/src/core/socket.test.ts @@ -39,7 +39,7 @@ describe('openLiveSocket', () => { FakeSocket.made = []; const drops: number[] = []; const live = openLiveSocket({ - url: 'https://w.example/connect/local/chat', + url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat', onMessage: () => {}, onDrop: (d) => drops.push(d.retryInMs), retry: { firstMs: 100, longestMs: 400 }, @@ -47,7 +47,7 @@ describe('openLiveSocket', () => { }); await vi.waitFor(() => expect(FakeSocket.made.length).toBe(1)); const first = FakeSocket.made[0]; - expect(first.url).toBe(`wss://w.example/connect/local/chat?session=${live.session}`); + expect(first.url).toBe(`wss://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat?session=${live.session}`); first.open(); first.drop(1006); live.send('while away'); @@ -67,14 +67,14 @@ describe('openLiveSocket', () => { FakeSocket.made = []; let ended = 0; openLiveSocket({ - url: 'http://127.0.0.1:14111/connect/local/chat', + url: 'http://127.0.0.1:14111/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat', session: 's1', onMessage: () => {}, onEnd: (e) => (ended = e.code), WebSocketImpl: FakeSocket as unknown as typeof WebSocket, }); await vi.waitFor(() => expect(FakeSocket.made.length).toBe(1)); - expect(FakeSocket.made[0].url).toBe('ws://127.0.0.1:14111/connect/local/chat?session=s1'); + expect(FakeSocket.made[0].url).toBe('ws://127.0.0.1:14111/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat?session=s1'); FakeSocket.made[0].open(); FakeSocket.made[0].drop(CONVERSATION_OVER); await flush(); @@ -87,7 +87,7 @@ describe('openLiveSocket', () => { let ended: { status?: number } | null = null; const fetcher = (async () => new Response('refused', { status: 401 })) as unknown as typeof fetch; const live = openLiveSocket({ - url: 'https://w.example/connect/local/chat', + url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat', headers: { 'Weft-Instance-Token': 'revoked' }, onMessage: () => {}, onEnd: (e) => (ended = e), @@ -106,7 +106,7 @@ describe('openLiveSocket', () => { FakeSocket.made = []; const drops: number[] = []; openLiveSocket({ - url: 'https://w.example/connect/local/chat', + url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat', onMessage: () => {}, onDrop: (d) => drops.push(d.retryInMs), retry: { firstMs: 100, longestMs: 1_000 }, @@ -135,10 +135,10 @@ describe('openLiveSocket', () => { const asked: { url: string; headers: HeadersInit | undefined }[] = []; const fetcher = (async (url: string, init?: RequestInit) => { asked.push({ url, headers: init?.headers }); - return new Response(JSON.stringify({ url: 'https://w.example/connect/local/chat?session=s2&wct=t', protocol: 'websocket' })); + return new Response(JSON.stringify({ url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat?session=s2&wct=t', protocol: 'websocket' })); }) as unknown as typeof fetch; openLiveSocket({ - url: 'https://w.example/connect/local/chat', + url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat', session: 's2', headers: { 'Weft-Instance-Token': 'wft-1' }, onMessage: () => {}, @@ -146,7 +146,7 @@ describe('openLiveSocket', () => { WebSocketImpl: FakeSocket as unknown as typeof WebSocket, }); await vi.waitFor(() => expect(FakeSocket.made.length).toBe(1)); - expect(asked).toEqual([{ url: 'https://w.example/connect/local/chat?session=s2', headers: { 'Weft-Instance-Token': 'wft-1' } }]); - expect(FakeSocket.made[0].url).toBe('wss://w.example/connect/local/chat?session=s2&wct=t'); + expect(asked).toEqual([{ url: 'https://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat?session=s2', headers: { 'Weft-Instance-Token': 'wft-1' } }]); + expect(FakeSocket.made[0].url).toBe('wss://w.example/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/chat?session=s2&wct=t'); }); }); diff --git a/packages/weft-connect/src/core/socket.ts b/packages/weft-connect/src/core/socket.ts index 30214e74..c4bce936 100644 --- a/packages/weft-connect/src/core/socket.ts +++ b/packages/weft-connect/src/core/socket.ts @@ -25,7 +25,7 @@ export const CONVERSATION_OVER = 4000; export const SETTLED_MS = 10_000; export interface LiveSocketOptions { - /** The route's address, `http(s)://.../connect//`. */ + /** The route's address, `http(s)://.../connect///`. */ url: string; /** The conversation this is, kept across reconnects. A fresh one is * made when absent; pass a stored one to resume a conversation after diff --git a/packages/weft-connect/src/server/passthrough.test.ts b/packages/weft-connect/src/server/passthrough.test.ts index 60cab8f5..954db0f7 100644 --- a/packages/weft-connect/src/server/passthrough.test.ts +++ b/packages/weft-connect/src/server/passthrough.test.ts @@ -101,12 +101,12 @@ describe('the pass-through', () => { }) as unknown as typeof fetch; const pass = weftPassThrough({ dispatcher: 'http://d', fetcher }); const res = await pass( - new Request(`${site}/connect/local/bot/ask`, { + new Request(`${site}/connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/bot/ask`, { method: 'POST', body: '{"text":"hello"}', headers: { 'Weft-Instance-Token': 'wft-m', 'content-type': 'application/json' }, }), - 'connect/local/bot/ask', + 'connect/local/2c65676f-ac18-496c-8e61-2d4a170d2f00/bot/ask', ); expect(await res.text()).toBe('{"reply":"hi"}'); // The dispatcher answers a route call in the one request, so its diff --git a/packages/weft-connect/src/server/passthrough.ts b/packages/weft-connect/src/server/passthrough.ts index dac0426f..14177092 100644 --- a/packages/weft-connect/src/server/passthrough.ts +++ b/packages/weft-connect/src/server/passthrough.ts @@ -16,7 +16,7 @@ import { trimTrailingSlashes } from '../core/url'; /** The first path segment of each door a page may reach through here: the * instance door, a signal's fire / skip / cancel door, the instance or * api token's listings (signals, displays, files), the program's own live - * routes (`connect//`), which a browser calls with its + * routes (`connect///`), which a browser calls with its * instance token, a field's provider chooser (`access/picker/` and the * `/result` it posts; nothing else under `access` passes), and a stored * file's link (`public/files/`: a route's answer names a picture by diff --git a/scripts/load-route.sh b/scripts/load-route.sh index 5296aa8f..1e50ed77 100755 --- a/scripts/load-route.sh +++ b/scripts/load-route.sh @@ -4,8 +4,8 @@ # # scripts/load-route.sh [calls] [at-once] [method] [body] # -# scripts/load-route.sh http://127.0.0.1:14111/connect/local/load/ping 1000 50 -# scripts/load-route.sh http://127.0.0.1:14111/connect/local/load/burn 200 20 POST '{"rounds":200000}' +# scripts/load-route.sh http://127.0.0.1:14111/connect/local//load/ping 1000 50 +# scripts/load-route.sh http://127.0.0.1:14111/connect/local//load/burn 200 20 POST '{"rounds":200000}' # # Every call has a 30 second cap, and the whole run is capped at ten # minutes. A call that hit its cap shows as status 000. The calls are made diff --git a/tangle/claude-code/.claude/agents/deployer.md b/tangle/claude-code/.claude/agents/deployer.md index c7708eb1..35a23a60 100644 --- a/tangle/claude-code/.claude/agents/deployer.md +++ b/tangle/claude-code/.claude/agents/deployer.md @@ -26,7 +26,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/claude-code/.claude/skills/weft-api/SKILL.md b/tangle/claude-code/.claude/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/claude-code/.claude/skills/weft-api/SKILL.md +++ b/tangle/claude-code/.claude/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/claude-code/.claude/skills/weft-deploying/SKILL.md b/tangle/claude-code/.claude/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/claude-code/.claude/skills/weft-deploying/SKILL.md +++ b/tangle/claude-code/.claude/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/claude-code/.claude/skills/weft-frontend/SKILL.md b/tangle/claude-code/.claude/skills/weft-frontend/SKILL.md index 4eae027b..24182a49 100644 --- a/tangle/claude-code/.claude/skills/weft-frontend/SKILL.md +++ b/tangle/claude-code/.claude/skills/weft-frontend/SKILL.md @@ -543,11 +543,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/claude-code/CLAUDE.md b/tangle/claude-code/CLAUDE.md index aa2ebb31..29bf2671 100644 --- a/tangle/claude-code/CLAUDE.md +++ b/tangle/claude-code/CLAUDE.md @@ -89,7 +89,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. A secret it needs from that infrastructure (a database password) is different: the specialist runs `weft infra press` and `weft infra env` itself, as one plain command from the project folder; if the permission checker refuses it one anyway, its report hands you the exact command, and you run it yourself and tell the same agent the env file is written. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. A secret it needs from that infrastructure (a database password) is different: the specialist runs `weft infra press` and `weft infra env` itself, as one plain command from the project folder; if the permission checker refuses it one anyway, its report hands you the exact command, and you run it yourself and tell the same agent the env file is written. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/cline/.cline/skills/deployer/SKILL.md b/tangle/cline/.cline/skills/deployer/SKILL.md index 637c599d..a095b16a 100644 --- a/tangle/cline/.cline/skills/deployer/SKILL.md +++ b/tangle/cline/.cline/skills/deployer/SKILL.md @@ -38,7 +38,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/cline/.cline/skills/weft-api/SKILL.md b/tangle/cline/.cline/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/cline/.cline/skills/weft-api/SKILL.md +++ b/tangle/cline/.cline/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/cline/.cline/skills/weft-deploying/SKILL.md b/tangle/cline/.cline/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/cline/.cline/skills/weft-deploying/SKILL.md +++ b/tangle/cline/.cline/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/cline/.cline/skills/weft-frontend/SKILL.md b/tangle/cline/.cline/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/cline/.cline/skills/weft-frontend/SKILL.md +++ b/tangle/cline/.cline/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/cline/.clinerules/tangle.md b/tangle/cline/.clinerules/tangle.md index 1c9b519a..b38c11ef 100644 --- a/tangle/cline/.clinerules/tangle.md +++ b/tangle/cline/.clinerules/tangle.md @@ -89,7 +89,7 @@ Cline's subagents are read-only research helpers it spawns on its own, and there [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/codex/.agents/skills/weft-api/SKILL.md b/tangle/codex/.agents/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/codex/.agents/skills/weft-api/SKILL.md +++ b/tangle/codex/.agents/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/codex/.agents/skills/weft-deploying/SKILL.md b/tangle/codex/.agents/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/codex/.agents/skills/weft-deploying/SKILL.md +++ b/tangle/codex/.agents/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/codex/.agents/skills/weft-frontend/SKILL.md b/tangle/codex/.agents/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/codex/.agents/skills/weft-frontend/SKILL.md +++ b/tangle/codex/.agents/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/codex/.codex/agents/deployer.toml b/tangle/codex/.codex/agents/deployer.toml index 7bf8deb6..25700adb 100644 --- a/tangle/codex/.codex/agents/deployer.toml +++ b/tangle/codex/.codex/agents/deployer.toml @@ -25,7 +25,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/codex/AGENTS.md b/tangle/codex/AGENTS.md index ff0d5164..8fc945ae 100644 --- a/tangle/codex/AGENTS.md +++ b/tangle/codex/AGENTS.md @@ -89,7 +89,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/cursor/.cursor/agents/deployer.md b/tangle/cursor/.cursor/agents/deployer.md index 94b3e3b5..4fc578ea 100644 --- a/tangle/cursor/.cursor/agents/deployer.md +++ b/tangle/cursor/.cursor/agents/deployer.md @@ -25,7 +25,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/cursor/.cursor/rules/tangle.mdc b/tangle/cursor/.cursor/rules/tangle.mdc index 67921843..cd7f8f94 100644 --- a/tangle/cursor/.cursor/rules/tangle.mdc +++ b/tangle/cursor/.cursor/rules/tangle.mdc @@ -94,7 +94,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/cursor/.cursor/skills/weft-api/SKILL.md b/tangle/cursor/.cursor/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/cursor/.cursor/skills/weft-api/SKILL.md +++ b/tangle/cursor/.cursor/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/cursor/.cursor/skills/weft-deploying/SKILL.md b/tangle/cursor/.cursor/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/cursor/.cursor/skills/weft-deploying/SKILL.md +++ b/tangle/cursor/.cursor/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/cursor/.cursor/skills/weft-frontend/SKILL.md b/tangle/cursor/.cursor/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/cursor/.cursor/skills/weft-frontend/SKILL.md +++ b/tangle/cursor/.cursor/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/devin-desktop/.devin/rules/tangle-3.md b/tangle/devin-desktop/.devin/rules/tangle-3.md index 402d4410..42a66f55 100644 --- a/tangle/devin-desktop/.devin/rules/tangle-3.md +++ b/tangle/devin-desktop/.devin/rules/tangle-3.md @@ -20,7 +20,7 @@ Devin Desktop has no subagent file format, so the six specialists are skills you [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/devin-desktop/.windsurf/skills/deployer/SKILL.md b/tangle/devin-desktop/.windsurf/skills/deployer/SKILL.md index 28f92f91..b638a68d 100644 --- a/tangle/devin-desktop/.windsurf/skills/deployer/SKILL.md +++ b/tangle/devin-desktop/.windsurf/skills/deployer/SKILL.md @@ -38,7 +38,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/devin-desktop/.windsurf/skills/weft-api/SKILL.md b/tangle/devin-desktop/.windsurf/skills/weft-api/SKILL.md index f05e84b7..7432c756 100644 --- a/tangle/devin-desktop/.windsurf/skills/weft-api/SKILL.md +++ b/tangle/devin-desktop/.windsurf/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/devin-desktop/.windsurf/skills/weft-deploying/SKILL.md b/tangle/devin-desktop/.windsurf/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/devin-desktop/.windsurf/skills/weft-deploying/SKILL.md +++ b/tangle/devin-desktop/.windsurf/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/devin-desktop/.windsurf/skills/weft-frontend/SKILL.md b/tangle/devin-desktop/.windsurf/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/devin-desktop/.windsurf/skills/weft-frontend/SKILL.md +++ b/tangle/devin-desktop/.windsurf/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/fallback/.agents/skills/deployer/SKILL.md b/tangle/fallback/.agents/skills/deployer/SKILL.md index 2010ee03..266a377e 100644 --- a/tangle/fallback/.agents/skills/deployer/SKILL.md +++ b/tangle/fallback/.agents/skills/deployer/SKILL.md @@ -24,7 +24,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/fallback/.agents/skills/weft-api/SKILL.md b/tangle/fallback/.agents/skills/weft-api/SKILL.md index f05e84b7..7432c756 100644 --- a/tangle/fallback/.agents/skills/weft-api/SKILL.md +++ b/tangle/fallback/.agents/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/fallback/.agents/skills/weft-deploying/SKILL.md b/tangle/fallback/.agents/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/fallback/.agents/skills/weft-deploying/SKILL.md +++ b/tangle/fallback/.agents/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/fallback/.agents/skills/weft-frontend/SKILL.md b/tangle/fallback/.agents/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/fallback/.agents/skills/weft-frontend/SKILL.md +++ b/tangle/fallback/.agents/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/fallback/AGENTS.md b/tangle/fallback/AGENTS.md index 82d054ce..fd93c08c 100644 --- a/tangle/fallback/AGENTS.md +++ b/tangle/fallback/AGENTS.md @@ -90,7 +90,7 @@ This file is Tangle's fallback shape, for an assistant weft has no template for. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/gemini-cli/.gemini/agents/deployer.md b/tangle/gemini-cli/.gemini/agents/deployer.md index 61610a9c..b7fbe144 100644 --- a/tangle/gemini-cli/.gemini/agents/deployer.md +++ b/tangle/gemini-cli/.gemini/agents/deployer.md @@ -28,7 +28,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/gemini-cli/.gemini/skills/weft-api/SKILL.md b/tangle/gemini-cli/.gemini/skills/weft-api/SKILL.md index f05e84b7..7432c756 100644 --- a/tangle/gemini-cli/.gemini/skills/weft-api/SKILL.md +++ b/tangle/gemini-cli/.gemini/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/gemini-cli/.gemini/skills/weft-deploying/SKILL.md b/tangle/gemini-cli/.gemini/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/gemini-cli/.gemini/skills/weft-deploying/SKILL.md +++ b/tangle/gemini-cli/.gemini/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/gemini-cli/.gemini/skills/weft-frontend/SKILL.md b/tangle/gemini-cli/.gemini/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/gemini-cli/.gemini/skills/weft-frontend/SKILL.md +++ b/tangle/gemini-cli/.gemini/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/gemini-cli/GEMINI.md b/tangle/gemini-cli/GEMINI.md index da2c2c47..757e375e 100644 --- a/tangle/gemini-cli/GEMINI.md +++ b/tangle/gemini-cli/GEMINI.md @@ -89,7 +89,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/github-copilot/.github/agents/deployer.agent.md b/tangle/github-copilot/.github/agents/deployer.agent.md index c07384b1..c280bd75 100644 --- a/tangle/github-copilot/.github/agents/deployer.agent.md +++ b/tangle/github-copilot/.github/agents/deployer.agent.md @@ -25,7 +25,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/github-copilot/.github/copilot-instructions.md b/tangle/github-copilot/.github/copilot-instructions.md index 68b41028..78406460 100644 --- a/tangle/github-copilot/.github/copilot-instructions.md +++ b/tangle/github-copilot/.github/copilot-instructions.md @@ -89,7 +89,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/github-copilot/.github/skills/weft-api/SKILL.md b/tangle/github-copilot/.github/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/github-copilot/.github/skills/weft-api/SKILL.md +++ b/tangle/github-copilot/.github/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/github-copilot/.github/skills/weft-deploying/SKILL.md b/tangle/github-copilot/.github/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/github-copilot/.github/skills/weft-deploying/SKILL.md +++ b/tangle/github-copilot/.github/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/github-copilot/.github/skills/weft-frontend/SKILL.md b/tangle/github-copilot/.github/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/github-copilot/.github/skills/weft-frontend/SKILL.md +++ b/tangle/github-copilot/.github/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/junie/.junie/AGENTS.md b/tangle/junie/.junie/AGENTS.md index cdfed9b2..379b1add 100644 --- a/tangle/junie/.junie/AGENTS.md +++ b/tangle/junie/.junie/AGENTS.md @@ -89,7 +89,7 @@ You have six specialists, and only these six. Junie chooses which one to delegat [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/junie/.junie/agents/deployer.md b/tangle/junie/.junie/agents/deployer.md index f87d47e8..65a6aa45 100644 --- a/tangle/junie/.junie/agents/deployer.md +++ b/tangle/junie/.junie/agents/deployer.md @@ -26,7 +26,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/junie/.junie/skills/weft-api/SKILL.md b/tangle/junie/.junie/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/junie/.junie/skills/weft-api/SKILL.md +++ b/tangle/junie/.junie/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/junie/.junie/skills/weft-deploying/SKILL.md b/tangle/junie/.junie/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/junie/.junie/skills/weft-deploying/SKILL.md +++ b/tangle/junie/.junie/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/junie/.junie/skills/weft-frontend/SKILL.md b/tangle/junie/.junie/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/junie/.junie/skills/weft-frontend/SKILL.md +++ b/tangle/junie/.junie/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/kilo-code/.kilo/agent/deployer.md b/tangle/kilo-code/.kilo/agent/deployer.md index a36b6dab..66ea3687 100644 --- a/tangle/kilo-code/.kilo/agent/deployer.md +++ b/tangle/kilo-code/.kilo/agent/deployer.md @@ -28,7 +28,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/kilo-code/.kilo/agent/tangle.md b/tangle/kilo-code/.kilo/agent/tangle.md index 8af203b6..13d67e7e 100644 --- a/tangle/kilo-code/.kilo/agent/tangle.md +++ b/tangle/kilo-code/.kilo/agent/tangle.md @@ -102,7 +102,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/kilo-code/.kilo/skills/weft-api/SKILL.md b/tangle/kilo-code/.kilo/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/kilo-code/.kilo/skills/weft-api/SKILL.md +++ b/tangle/kilo-code/.kilo/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/kilo-code/.kilo/skills/weft-deploying/SKILL.md b/tangle/kilo-code/.kilo/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/kilo-code/.kilo/skills/weft-deploying/SKILL.md +++ b/tangle/kilo-code/.kilo/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/kilo-code/.kilo/skills/weft-frontend/SKILL.md b/tangle/kilo-code/.kilo/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/kilo-code/.kilo/skills/weft-frontend/SKILL.md +++ b/tangle/kilo-code/.kilo/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/opencode/.opencode/agents/deployer.md b/tangle/opencode/.opencode/agents/deployer.md index a36b6dab..66ea3687 100644 --- a/tangle/opencode/.opencode/agents/deployer.md +++ b/tangle/opencode/.opencode/agents/deployer.md @@ -28,7 +28,7 @@ Every command you run has a cap, written into the command itself: `timeout ` answers. A missing key ends here, with `weft login ` named for the user. 3. Do [the ask], as the skill lays it out. A deploy is `weft activate --on ` while the program is off, and `weft resync --on --mode park` once its triggers are on (`weft status --on ` says which; `hibernate` if the user prefers it, `wipe` only when the user says so), started in the background under its cap: 6 minutes when it builds images (the first deploy of a project, or a change to its nodes), 1 minute when nothing builds. While it runs, read its output every 30 seconds: it names each image it builds and where that build's log is. At the cap, check `weft status --on `: if a build is still moving there, give it one more period; if not, stop the command and report the silence, the status output, and the last lines it printed. -4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local/` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. +4. Prove it: `weft status --on ` shows the project active on the version you deployed. For each route, call `/connect/local//` once with no credentials and quote the status code: a 401 or 403 means the route is live and gated. Never send a call that would get past a gate on a cloud install: it starts a real run. ## Report diff --git a/tangle/opencode/.opencode/agents/tangle.md b/tangle/opencode/.opencode/agents/tangle.md index ad165b1a..17f52eef 100644 --- a/tangle/opencode/.opencode/agents/tangle.md +++ b/tangle/opencode/.opencode/agents/tangle.md @@ -102,7 +102,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure. diff --git a/tangle/opencode/.opencode/skills/weft-api/SKILL.md b/tangle/opencode/.opencode/skills/weft-api/SKILL.md index 6f4639ef..66ed1400 100644 --- a/tangle/opencode/.opencode/skills/weft-api/SKILL.md +++ b/tangle/opencode/.opencode/skills/weft-api/SKILL.md @@ -21,18 +21,18 @@ storage, the only way bytes travel a wire. ## The URL is known before anything runs -A [route] answers at `/connect/local/`, and every part of that address is fixed by the install before the route is ever activated. On this machine the install +A [route] answers at `/connect/local//`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at -`http://127.0.0.1:14111/connect/local/hello`, and a socket at the same +`http://127.0.0.1:14111/connect/local//hello`, and a socket at the same address with `ws://`. On a cloud install it is the target's `url` in -`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/hello`, +`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local//hello`, and `wss://`). You write those URLs into the frontend-builder's [the brief] the moment the routes are shaped, while the graph is still being built; `weft activate` only turns them on. -Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local/` answers 404. When you brief a frontend, give it the `/connect/local/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. +Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port ` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry on a 503; once the project is switched off (wiped), its own port is closed and `/connect/local//` answers 404. When you brief a frontend, give it the `/connect/local//` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud. -Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local/`, reaching the same program. A frontend that runs +Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `/connect/local//`, reaching the same program. A frontend that runs anywhere else (a hosted site, a phone, a browser on another machine) uses the public one; a server on this same machine may use either. @@ -41,7 +41,7 @@ If the user wants one and has agreed to its price, the deployer runs `weft domain add api.shop.com --for api --on prod` (with `--accept-cost` for the install's first domain), which serves the project's routes at the root of that domain, so `hello` answers at `https://api.shop.com/hello` as well as at its -`/connect/local/` address. A domain costs money, because it needs a load balancer, so +`/connect/local//` address. A domain costs money, because it needs a load balancer, so design for the free address and offer a domain only when the user wants a name people see; read weft-deploying before you offer one. diff --git a/tangle/opencode/.opencode/skills/weft-deploying/SKILL.md b/tangle/opencode/.opencode/skills/weft-deploying/SKILL.md index e81cbaa1..68d3ff3e 100644 --- a/tangle/opencode/.opencode/skills/weft-deploying/SKILL.md +++ b/tangle/opencode/.opencode/skills/weft-deploying/SKILL.md @@ -49,7 +49,7 @@ shows every domain and its record. A domain can serve one project instead of the whole install: `--for api` serves that project's routes at the root of the domain (`https://api.shop.com/users/42`, on top of -`/connect/local/users/42`), and `--for frontend --to /connect/local//users/42`), and `--for frontend --to ` passes the domain on to the project's frontend (`weft frontend ls --on prod` shows that address). @@ -213,8 +213,9 @@ Activate never starts infrastructure. On a first deploy of a program with infra (a database, a bridge), run `weft infra start --on prod` first (step 6 above, which builds the program too): if a trigger reads infra that is not running, activate refuses with "these triggers' infra is not running: -". On GCP each piece boots a machine, so it takes a few minutes; -`weft status --on prod` shows each one's state. +", and while it is still starting, with "this program's infra is +changing (infra provisioning)". On GCP each piece boots a machine, so it +takes a few minutes; `weft status --on prod` shows each one's state. `weft activate` is for a program that is off. Once its triggers are on, it refuses, and a change goes live with `weft resync --on prod --mode `, diff --git a/tangle/opencode/.opencode/skills/weft-frontend/SKILL.md b/tangle/opencode/.opencode/skills/weft-frontend/SKILL.md index a5c9ef34..d3072cfb 100644 --- a/tangle/opencode/.opencode/skills/weft-frontend/SKILL.md +++ b/tangle/opencode/.opencode/skills/weft-frontend/SKILL.md @@ -538,11 +538,11 @@ The shape, unless the user asks for another: `/signal-token/`, `http://127.0.0.1:14111` on a local install) goes in the server's environment and nowhere a page can read it. The pass-through forwards only `/instance/`, `/signal/`, `/signal-token/`, the program's - own routes (`/connect//`) and the picture links in their answers + own routes (`/connect///`) and the picture links in their answers (`/public/files/`), carries the caller's token and body, and keeps the site's cookies and the `Weft-Instance` header back. A page fetches `/weft/signal-token/displays` with the instance token as bearer, calls a - program route as `/weft/connect//` with the instance token in + program route as `/weft/connect///` with the instance token in `Weft-Instance-Token`, and `new InstanceDoor(instanceToken)` already calls `/weft/instance/...`. A route called through the pass-through builds the picture links in its answer on the site's own address, so those pictures diff --git a/tangle/opencode/AGENTS.md b/tangle/opencode/AGENTS.md index e8b001a2..779aad13 100644 --- a/tangle/opencode/AGENTS.md +++ b/tangle/opencode/AGENTS.md @@ -89,7 +89,7 @@ There are six kinds of specialist, and you dispatch no others. [the brief] to a prompt-engineer: the job this LLM call does, in one sentence; the node and the model that will run it; the data that arrives on the wires; the shape of what must come back (and the JSON keys, when the node parses them); the failure modes the stage must not fall for and what must happen when one shows up; any existing prompt worth overhauling instead of starting fresh. -[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local/`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. +[the brief] to a frontend-builder: the one task a person must be able to do end to end; the program's own routes it calls, each with its full URL and the body it takes and returns. Say which body keys carry a picture, and that a picture comes back as a link. And say, per route, WHO may call it: anyone, or a signed-in person, or only the owner of the thing it touches. A route that deletes, sends, spends or moderates always needs the last two, and the brief has to name which, because holding the api token is not the same as being allowed to use it and a server route that skips that check is a public button on a privileged action. The URL is known before anything runs: `/connect/local//`, where the install is `http://127.0.0.1:14111` on this machine (or the `public` port in `~/.local/share/weft/ports.json` if it was moved) and the target's `url` in `weft.toml` on a cloud install, the project id is the `id` under `[package]` in the project's `weft.toml`, and the path is the one you gave the route node (the `weft-api` skill). The frontend's server reads the install's address from `WEFT_DISPATCHER_URL` and its token from `WEFT_TOKEN`, so the same code runs here and on the cloud (the `weft-frontend` skill). Then the signals it shows and fires, with their `kind` and fields and the doors a client reaches them through (the `weft-consumers` skill). If a page is to show what one of the program's nodes is SHOWING (a WhatsApp bridge's QR code to scan, a database's minted password), name those nodes and mint the token with `--display ` for each, from the project's folder, spelling each node the way it is written in the source: that scope grants nothing by default, so a token minted without it reads no display and the specialist gets a 403 it cannot fix. Then whether it needs auth, and who logs in; the stack the user named, or that it is the defaults. If the frontend needs to speak to a piece of [the program]'s own infrastructure (a database for its sign-in tables, a cache, a broker), it finds the address itself with `weft infra list-doors` and you say nothing about it in the brief. What IS yours is whether the thing is reachable at all: that is written in the node that runs it, as an input its author gave it, so you read that node's own description, set it when you write the node in, and the door is there before the specialist looks. A specialist that reports a door it needs and cannot see has done the right thing: you make it reachable, or build what was missing, then dispatch again to finish that part. The specialist never invents a second backend and never routes a page around the program to a service the program does not use. [the review] on every node-smith report: you re-run `weft test-node ` yourself, you diff the delivered `metadata.json` against the reported ports, and you read every test asking how it would fail. Then the contract check: ports held, no job creep, live-tier tests written and named as not run, the validate run still passing. You read the body against the fail-loudly rule (under How you write weft). The full checklist, including the shapes half-arsed work takes (smoke-only tests, weakened assertions, happy-path-only coverage), is in the `weft-node-authoring` skill. A report that fails goes back out as a new dispatch carrying the previous attempt's folder (`nodes//`, as it is) and the specific finding; a report that claimed green and runs red is redispatched with the dishonesty named. When a landed node is a public service general enough for other projects, you offer the user the contribution path once (the `weft-gaps` skill), their click, no pressure.