Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .kilo/agent/working-partner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions crates/weft-access-store/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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| {
Expand Down
7 changes: 4 additions & 3 deletions crates/weft-broker/src/door.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<anyhow::Result<Vec<_>>>().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::<anyhow::Result<Vec<_>>>().map_err(unavailable_or_internal)?;
Ok(Json(DoorTriggers { triggers }))
}

Expand Down Expand Up @@ -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<DoorTrigger> {
fn door_trigger(row: &sqlx::postgres::PgRow, mount: weft_core::route::SharedMount<'_>) -> anyhow::Result<DoorTrigger> {
let node_id: String = row.try_get("node_id")?;
let surface_kind: String = row.try_get("surface_kind")?;
let route = match row.try_get::<Option<String>, _>("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,
Expand Down
9 changes: 6 additions & 3 deletions crates/weft-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"))
}

Expand Down
32 changes: 19 additions & 13 deletions crates/weft-cli/src/commands/infra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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.
Expand Down
18 changes: 11 additions & 7 deletions crates/weft-cli/src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
2 changes: 1 addition & 1 deletion crates/weft-cli/src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions crates/weft-core/src/infra/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ pub struct PerNodeRequest {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfraStatus {
pub nodes: Vec<InfraStatusEntry>,
/// 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<crate::projects::Unanswered>,
}

impl InfraStatus {
Expand Down
70 changes: 68 additions & 2 deletions crates/weft-core/src/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -111,7 +113,7 @@ pub fn tls_config() -> Result<Arc<rustls::ClientConfig>, 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/<tenant>/...`, an API
/// from an address the install shares (`/connect/<tenant>/<project>/...`, 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
Expand All @@ -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/<tenant>`, or empty at a project's own address): what a
/// (`/connect/<tenant>/<project>`, 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.
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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);
}
}
12 changes: 7 additions & 5 deletions crates/weft-core/src/primitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// `<dispatcher_base>/connect/<tenant>/<path>` (a live route) or
/// `POST <dispatcher_base>/<tenant>/<path>` (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
/// `<dispatcher_base>/connect/<tenant>/<project>/<path>` (a live route)
/// or `POST <dispatcher_base>/<tenant>/<project>/<path>` (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 {
Expand Down
38 changes: 38 additions & 0 deletions crates/weft-core/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TriggerRuns>,
/// 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<Unanswered>,
}

/// 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
Expand Down
Loading
Loading