Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Compare diffs from where the two branches last agreed, as a pull request would | 1 | //! Memory: what agents and people have learned that the next agent should |
| 2 | //! know, at two levels. A project's memory is about its codebase; the | |
| 3 | //! workspace's is true across its projects ("we use pnpm everywhere", | |
| 4 | //! "staging lives at …"). | |
| 5 | //! | |
| 6 | //! A workspace's memory is members-only, since it can hold internal | |
| 7 | //! knowledge; a project's is for whoever can read the project, and changed | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 8 | //! by whoever can push to it. It never holds a secret: text that looks like a key or a token is refused. |
| 9 | //! Agents read it with `recall` and add to it with `remember` (MCP), and | |
| 10 | //! people manage it on the Memory pages. | |
| Compare diffs from where the two branches last agreed, as a pull request would | 11 | |
| 12 | use g1t_contracts::access::{self, Capability}; | |
| 13 | use g1t_contracts::agents::*; | |
| 14 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | use g1t_contracts::time::rfc3339; | |
| 16 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id}; | |
| 17 | use g1t_kit::now_ms; | |
| 18 | use serde::Deserialize; | |
| 19 | use worker::Result; | |
| 20 | use worker::wasm_bindgen::JsValue; | |
| 21 | ||
| 22 | use crate::Work; | |
| 23 | ||
| 24 | /// A workspace renamed: its runs and memories move to the slug it has now. | |
| 25 | /// `?1` is the current slug, `?2` a stale one (see `g1t_kit::rename`). | |
| 26 | pub(crate) const RENAMED: &[&str] = &[ | |
| 27 | "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2", | |
| 28 | "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 29 | "UPDATE memories SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2", | |
| 30 | "UPDATE memories SET workspace = ?1 WHERE workspace = ?2", | |
| 31 | "UPDATE memories SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 32 | "UPDATE memories SET source_repo = ?1 || substr(source_repo, length(?2) + 1) WHERE substr(source_repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 33 | ]; | |
| 34 | ||
| 35 | /// A repository transferred (see `g1t_kit::transfer`): its agent runs and | |
| 36 | /// its project's memory go with it, as its issues and pull requests do by | |
| 37 | /// id. `?1`/`?2` the path now and before, `?3`/`?4` the workspaces, `?5` | |
| 38 | /// the repository's id. | |
| 39 | pub(crate) const TRANSFERRED: &[&str] = &[ | |
| 40 | "UPDATE agent_runs SET workspace = ?3, repo = ?1 WHERE repo_id = ?5 AND repo = ?2", | |
| 41 | "UPDATE memories SET workspace = ?3, repo = ?1 WHERE scope = 'project' AND scope_key = ?5 AND workspace = ?4", | |
| 42 | "UPDATE memories SET source_repo = ?1 WHERE source_repo = ?2", | |
| 43 | ]; | |
| 44 | ||
| 45 | /// A workspace deleted: its own memory, guardrails and queued runs go. | |
| 46 | /// `?1` is its slug. Project memory went with each repository's transfer. | |
| 47 | pub(crate) const DELETED: &[&str] = &[ | |
| 48 | "DELETE FROM memories WHERE scope = 'workspace' AND scope_key = ?1", | |
| 49 | "DELETE FROM guardrails WHERE scope = 'workspace' AND scope_key = ?1", | |
| 50 | ]; | |
| 51 | ||
| 52 | /// The most memories one project, or one workspace, keeps. | |
| 53 | const MAX_PER_SCOPE: u32 = 500; | |
| 54 | const DEFAULT_RECALL: u32 = 20; | |
| 55 | ||
| 56 | #[derive(Deserialize)] | |
| 57 | pub(crate) struct MemoryRow { | |
| 58 | id: String, | |
| 59 | pub(crate) scope: String, | |
| 60 | workspace: String, | |
| 61 | repo: Option<String>, | |
| 62 | text: String, | |
| 63 | kind: String, | |
| 64 | source_kind: String, | |
| 65 | source_run: Option<String>, | |
| 66 | source_repo: Option<String>, | |
| 67 | source_number: Option<u32>, | |
| 68 | created_by: String, | |
| 69 | pinned: u32, | |
| 70 | created_at: String, | |
| 71 | updated_at: String, | |
| 72 | last_used_at: Option<String>, | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 73 | // What automatic capture added (migration 0019); read for memories |
| 74 | // kept from before it was removed. | |
| Compare diffs from where the two branches last agreed, as a pull request would | 75 | #[serde(default)] |
| 76 | status: Option<String>, | |
| 77 | #[serde(default)] | |
| 78 | confidence: Option<f64>, | |
| 79 | #[serde(default)] | |
| 80 | source_ref: Option<String>, | |
| 81 | #[serde(default)] | |
| 82 | evidence: Option<String>, | |
| 83 | #[serde(default)] | |
| 84 | sources: Option<String>, | |
| 85 | } | |
| 86 | ||
| 87 | fn path(text: &str) -> Option<RepoPath> { | |
| 88 | let (namespace, name) = text.split_once('/')?; | |
| 89 | Some(RepoPath { | |
| 90 | namespace: namespace.to_owned(), | |
| 91 | name: name.to_owned(), | |
| 92 | }) | |
| 93 | } | |
| 94 | ||
| 95 | impl From<MemoryRow> for Memory { | |
| 96 | fn from(row: MemoryRow) -> Self { | |
| 97 | Memory { | |
| 98 | id: row.id, | |
| 99 | scope: if row.scope == "workspace" { | |
| 100 | MemoryScope::Workspace | |
| 101 | } else { | |
| 102 | MemoryScope::Project | |
| 103 | }, | |
| 104 | workspace: row.workspace, | |
| 105 | repo: row.repo.as_deref().and_then(path), | |
| 106 | text: row.text, | |
| 107 | kind: MemoryKind::parse(&row.kind).unwrap_or_default(), | |
| 108 | source: MemorySource { | |
| 109 | kind: row.source_kind, | |
| 110 | run_id: row.source_run, | |
| 111 | repo: row.source_repo.as_deref().and_then(path), | |
| 112 | number: row.source_number, | |
| 113 | reference: row.source_ref, | |
| 114 | evidence: row.evidence, | |
| 115 | }, | |
| 116 | created_by: row.created_by, | |
| 117 | pinned: row.pinned != 0, | |
| 118 | created_at: row.created_at, | |
| 119 | updated_at: row.updated_at, | |
| 120 | last_used_at: row.last_used_at, | |
| 121 | status: row.status.as_deref().and_then(MemoryStatus::parse).unwrap_or_default(), | |
| 122 | confidence: row.confidence, | |
| 123 | seen: row | |
| 124 | .sources | |
| 125 | .as_deref() | |
| 126 | .and_then(|sources| serde_json::from_str::<Vec<String>>(sources).ok()) | |
| 127 | .map_or(1, |sources| sources.len().max(1) as u32), | |
| 128 | } | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | /// The text tidied, or why it cannot be kept. | |
| 133 | fn valid_text(text: &str) -> std::result::Result<String, String> { | |
| 134 | let text = text.trim(); | |
| 135 | if text.is_empty() { | |
| 136 | return Err("Write what should be remembered.".into()); | |
| 137 | } | |
| 138 | if text.chars().count() > MAX_MEMORY_CHARS { | |
| 139 | return Err(format!( | |
| 140 | "Keep a memory to {MAX_MEMORY_CHARS} characters: one fact, convention, decision or gotcha each." | |
| 141 | )); | |
| 142 | } | |
| 143 | if let Some(what) = secret_in(text) { | |
| 144 | return Err(format!( | |
| 145 | "That looks like {what}. Memory is read by every agent in the workspace, so it never holds secrets. Say where the secret lives instead, such as \"the deploy key is the DEPLOY_KEY secret\"." | |
| 146 | )); | |
| 147 | } | |
| 148 | Ok(text.to_owned()) | |
| 149 | } | |
| 150 | ||
| 151 | /// Why a person cannot change a memory. | |
| 152 | const CHANGE_DENIED: &str = "Only members of the workspace can change its memory, and a project's only with the Write role on it."; | |
| 153 | ||
| 154 | fn denied<T>() -> Outcome<T> { | |
| 155 | Outcome::fail( | |
| 156 | FailureCode::Forbidden, | |
| 157 | "Memory is for members of the workspace and its agents.", | |
| 158 | ) | |
| 159 | } | |
| 160 | ||
| 161 | /// Whether `actor` may change a memory of `workspace`: the workspace's own | |
| 162 | /// needs a member; a project's (`repo_id`, its repository) the Write role | |
| 163 | /// on that repository, as pushing to it does. | |
| 164 | fn may_write(actor: &User, workspace: &str, repo_id: Option<&str>) -> bool { | |
| 165 | let able = actor.verified || actor.kind != PrincipalKind::User; | |
| 166 | able && match repo_id { | |
| 167 | None => actor.is_member(workspace), | |
| 168 | // Write is never had through being public, so treating it as | |
| 169 | // private changes nothing. | |
| 170 | Some(id) => access::can( | |
| 171 | Some(actor), | |
| 172 | access::RepoRef { id, namespace: workspace, private: true }, | |
| 173 | Capability::Push, | |
| 174 | ), | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | /// The order memories are given and listed in: pinned, then most recently | |
| 179 | /// used or changed. | |
| 180 | const ORDER: &str = "pinned DESC, COALESCE(last_used_at, updated_at) DESC, created_at DESC"; | |
| 181 | ||
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 182 | /// A memory's text folded for comparing: lowercase words and digits, one |
| 183 | /// space apart, so wording that differs only in case, punctuation or | |
| 184 | /// spacing is the same memory. | |
| 185 | fn fingerprint(text: &str) -> String { | |
| 186 | text.to_lowercase() | |
| 187 | .split(|c: char| !c.is_alphanumeric()) | |
| 188 | .filter(|word| !word.is_empty()) | |
| 189 | .collect::<Vec<_>>() | |
| 190 | .join(" ") | |
| Compare diffs from where the two branches last agreed, as a pull request would | 191 | } |
| 192 | ||
| 193 | impl Work { | |
| 194 | async fn memories_of(&self, scope: MemoryScope, key: &str, limit: u32) -> Result<Vec<Memory>> { | |
| 195 | Ok(self | |
| 196 | .db | |
| 197 | .prepare(format!( | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 198 | // Only what is kept: candidates captured before are not shown. |
| Compare diffs from where the two branches last agreed, as a pull request would | 199 | "SELECT * FROM memories WHERE scope = ? AND scope_key = ? AND status = 'kept' ORDER BY {ORDER} LIMIT ?" |
| 200 | )) | |
| 201 | .bind(&[scope.as_str().into(), key.into(), limit.into()])? | |
| 202 | .all() | |
| 203 | .await? | |
| 204 | .results::<MemoryRow>()? | |
| 205 | .into_iter() | |
| 206 | .map(Memory::from) | |
| 207 | .collect()) | |
| 208 | } | |
| 209 | ||
| 210 | async fn memory_row(&self, workspace: &str, id: &str) -> Result<Option<Memory>> { | |
| 211 | Ok(self | |
| 212 | .db | |
| 213 | .prepare("SELECT * FROM memories WHERE id = ? AND workspace = ?") | |
| 214 | .bind(&[id.into(), workspace.into()])? | |
| 215 | .first::<MemoryRow>(None) | |
| 216 | .await? | |
| 217 | .map(Memory::from)) | |
| 218 | } | |
| 219 | ||
| 220 | /// The repository a project's memory is about; none for the workspace's. | |
| 221 | async fn memory_repo_id(&self, id: &str) -> Result<Option<String>> { | |
| 222 | self | |
| 223 | .db | |
| 224 | .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'") | |
| 225 | .bind(&[id.into()])? | |
| 226 | .first::<String>(Some("value")) | |
| 227 | .await | |
| 228 | } | |
| 229 | ||
| 230 | async fn mark_used(&self, ids: &[String]) -> Result<()> { | |
| 231 | if ids.is_empty() { | |
| 232 | return Ok(()); | |
| 233 | } | |
| 234 | self.db | |
| 235 | .prepare("UPDATE memories SET last_used_at = ? WHERE id IN (SELECT value FROM json_each(?))") | |
| 236 | .bind(&[rfc3339(now_ms()).into(), serde_json::to_string(ids)?.into()])? | |
| 237 | .run() | |
| 238 | .await?; | |
| 239 | Ok(()) | |
| 240 | } | |
| 241 | ||
| 242 | /// The project's repository, which must be in `workspace`. | |
| 243 | async fn project_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> { | |
| 244 | Ok(match self.repo(path, viewer).await? { | |
| 245 | Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo), | |
| 246 | Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."), | |
| 247 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 248 | }) | |
| 249 | } | |
| 250 | ||
| 251 | pub(crate) async fn list_memories(&self, a: ListMemoriesArgs) -> Result<Outcome<Memories>> { | |
| 252 | let workspace = a.workspace.to_lowercase(); | |
| 253 | let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)); | |
| 254 | // Someone else who can read the project sees its memory alone. | |
| 255 | if !member && a.repo.is_none() { | |
| 256 | return Ok(denied()); | |
| 257 | } | |
| 258 | let project = match &a.repo { | |
| 259 | Some(path) => match self.project_repo(path, &a.viewer, &workspace).await? { | |
| 260 | Outcome::Ok(repo) => self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?, | |
| 261 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 262 | }, | |
| 263 | None => Vec::new(), | |
| 264 | }; | |
| 265 | let workspace = if member { | |
| 266 | self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await? | |
| 267 | } else { | |
| 268 | Vec::new() | |
| 269 | }; | |
| 270 | Ok(Outcome::Ok(Memories { project, workspace })) | |
| 271 | } | |
| 272 | ||
| 273 | pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> { | |
| 274 | let workspace = a.workspace.to_lowercase(); | |
| 275 | if a.scope == MemoryScope::Workspace && !may_write(&a.actor, &workspace, None) { | |
| 276 | return Ok(denied()); | |
| 277 | } | |
| 278 | let text = match valid_text(&a.text) { | |
| 279 | Ok(text) => text, | |
| 280 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 281 | }; | |
| 282 | let viewer = Some(a.actor.clone()); | |
| 283 | let repo = match &a.repo { | |
| 284 | Some(path) => match self.project_repo(path, &viewer, &workspace).await? { | |
| 285 | Outcome::Ok(repo) => Some(repo), | |
| 286 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 287 | }, | |
| 288 | None => None, | |
| 289 | }; | |
| 290 | let key = match (a.scope, &repo) { | |
| 291 | (MemoryScope::Workspace, _) => workspace.clone(), | |
| 292 | (MemoryScope::Project, Some(repo)) => { | |
| 293 | if !may_write(&a.actor, &workspace, Some(&repo.id)) { | |
| 294 | return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED)); | |
| 295 | } | |
| 296 | repo.id.clone() | |
| 297 | } | |
| 298 | (MemoryScope::Project, None) => { | |
| 299 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about.")); | |
| 300 | } | |
| 301 | }; | |
| 302 | // The same thing remembered twice is one memory, freshened; written | |
| 303 | // by hand, a candidate or a dismissed one with the same words is | |
| 304 | // kept. | |
| 305 | let now = rfc3339(now_ms()); | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 306 | let print = fingerprint(&text); |
| Compare diffs from where the two branches last agreed, as a pull request would | 307 | let same = self |
| 308 | .db | |
| 309 | .prepare( | |
| 310 | "UPDATE memories SET updated_at = ?, status = 'kept' | |
| 311 | WHERE scope = ? AND scope_key = ? AND (text = ? OR fingerprint = ?) RETURNING id AS value", | |
| 312 | ) | |
| 313 | .bind(&[now.as_str().into(), a.scope.as_str().into(), key.as_str().into(), text.as_str().into(), print.as_str().into()])? | |
| 314 | .first::<String>(Some("value")) | |
| 315 | .await?; | |
| 316 | if let Some(id) = same { | |
| 317 | return Ok(match self.memory_row(&workspace, &id).await? { | |
| 318 | Some(memory) => Outcome::Ok(memory), | |
| 319 | None => Outcome::fail(FailureCode::NotFound, "Memory not found."), | |
| 320 | }); | |
| 321 | } | |
| 322 | let count = self | |
| 323 | .db | |
| 324 | .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'") | |
| 325 | .bind(&[a.scope.as_str().into(), key.as_str().into()])? | |
| 326 | .first::<u32>(Some("value")) | |
| 327 | .await? | |
| 328 | .unwrap_or_default(); | |
| 329 | if count >= MAX_PER_SCOPE { | |
| 330 | return Ok(Outcome::fail( | |
| 331 | FailureCode::Conflict, | |
| 332 | format!("This {} already keeps {MAX_PER_SCOPE} memories. Remove some that no longer hold first.", a.scope.as_str()), | |
| 333 | )); | |
| 334 | } | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 335 | // Where it came from: a person, or an agent working for one. |
| Compare diffs from where the two branches last agreed, as a pull request would | 336 | let from = match (&repo, a.from_number) { |
| 337 | (Some(repo), Some(number)) => Some((repo, number)), | |
| 338 | _ => None, | |
| 339 | }; | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 340 | let source_kind = match a.actor.kind { |
| 341 | PrincipalKind::User => "person", | |
| Compare diffs from where the two branches last agreed, as a pull request would | 342 | _ => "agent", |
| 343 | }; | |
| 344 | let id = new_id("mem", now_ms()); | |
| 345 | let repo_text = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)); | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 346 | // Its source, as earlier memories record theirs. |
| 347 | let reference = format!("{source_kind}:{}", a.actor.username); | |
| Compare diffs from where the two branches last agreed, as a pull request would | 348 | self.db |
| 349 | .prepare( | |
| 350 | "INSERT INTO memories | |
| 351 | (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run, | |
| 352 | source_repo, source_number, created_by, pinned, created_at, updated_at, | |
| 353 | status, fingerprint, sources, source_ref) | |
| 354 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)", | |
| 355 | ) | |
| 356 | .bind(&[ | |
| 357 | id.as_str().into(), | |
| 358 | a.scope.as_str().into(), | |
| 359 | key.clone().into(), | |
| 360 | workspace.as_str().into(), | |
| 361 | // A workspace's memory belongs to no one project, though it | |
| 362 | // remembers which it was learned in. | |
| 363 | if a.scope == MemoryScope::Project { repo_text.clone().map_or(JsValue::NULL, JsValue::from) } else { JsValue::NULL }, | |
| 364 | text.into(), | |
| 365 | a.kind.as_str().into(), | |
| 366 | source_kind.into(), | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 367 | JsValue::NULL, |
| Compare diffs from where the two branches last agreed, as a pull request would | 368 | repo_text.map_or(JsValue::NULL, JsValue::from), |
| 369 | from.map_or(JsValue::NULL, |(_, number)| number.into()), | |
| 370 | a.actor.username.as_str().into(), | |
| 371 | u32::from(a.pinned).into(), | |
| 372 | now.as_str().into(), | |
| 373 | now.as_str().into(), | |
| 374 | print.as_str().into(), | |
| 375 | serde_json::to_string(&[&reference])?.into(), | |
| 376 | reference.as_str().into(), | |
| 377 | ])? | |
| 378 | .run() | |
| 379 | .await?; | |
| 380 | Ok(match self.memory_row(&workspace, &id).await? { | |
| 381 | Some(memory) => Outcome::Ok(memory), | |
| 382 | None => Outcome::fail(FailureCode::NotFound, "Memory not found."), | |
| 383 | }) | |
| 384 | } | |
| 385 | ||
| 386 | pub(crate) async fn update_memory(&self, a: UpdateMemoryArgs) -> Result<Outcome<Memory>> { | |
| 387 | let workspace = a.workspace.to_lowercase(); | |
| 388 | let repo_id = self.memory_repo_id(&a.id).await?; | |
| 389 | if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent { | |
| 390 | return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED)); | |
| 391 | } | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 392 | if self.memory_row(&workspace, &a.id).await?.is_none() { |
| Compare diffs from where the two branches last agreed, as a pull request would | 393 | return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found.")); |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 394 | } |
| Compare diffs from where the two branches last agreed, as a pull request would | 395 | let text = match a.text.as_deref().map(valid_text) { |
| 396 | Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 397 | Some(Ok(text)) => Some(text), | |
| 398 | None => None, | |
| 399 | }; | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 400 | let print = text.as_deref().map(fingerprint); |
| Compare diffs from where the two branches last agreed, as a pull request would | 401 | self.db |
| 402 | .prepare( | |
| 403 | "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind), | |
| 404 | pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ? | |
| 405 | WHERE id = ? AND workspace = ?", | |
| 406 | ) | |
| 407 | .bind(&[ | |
| 408 | text.map_or(JsValue::NULL, JsValue::from), | |
| 409 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), | |
| 410 | a.pinned.map_or(JsValue::NULL, |pinned| u32::from(pinned).into()), | |
| 411 | print.map_or(JsValue::NULL, JsValue::from), | |
| 412 | rfc3339(now_ms()).into(), | |
| 413 | a.id.as_str().into(), | |
| 414 | workspace.as_str().into(), | |
| 415 | ])? | |
| 416 | .run() | |
| 417 | .await?; | |
| 418 | Ok(match self.memory_row(&workspace, &a.id).await? { | |
| 419 | Some(memory) => Outcome::Ok(memory), | |
| 420 | None => Outcome::fail(FailureCode::NotFound, "Memory not found."), | |
| 421 | }) | |
| 422 | } | |
| 423 | ||
| 424 | pub(crate) async fn delete_memory(&self, a: DeleteMemoryArgs) -> Result<Outcome<bool>> { | |
| 425 | let workspace = a.workspace.to_lowercase(); | |
| 426 | let repo_id = self.memory_repo_id(&a.id).await?; | |
| 427 | if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent { | |
| 428 | return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED)); | |
| 429 | } | |
| 430 | let gone = self | |
| 431 | .db | |
| 432 | .prepare("DELETE FROM memories WHERE id = ? AND workspace = ? RETURNING id AS value") | |
| 433 | .bind(&[a.id.as_str().into(), workspace.as_str().into()])? | |
| 434 | .first::<String>(Some("value")) | |
| 435 | .await?; | |
| 436 | Ok(match gone { | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 437 | Some(_) => Outcome::Ok(true), |
| Compare diffs from where the two branches last agreed, as a pull request would | 438 | None => Outcome::fail(FailureCode::NotFound, "Memory not found."), |
| 439 | }) | |
| 440 | } | |
| 441 | ||
| 442 | pub(crate) async fn recall(&self, a: RecallArgs) -> Result<Outcome<Memories>> { | |
| 443 | let repo = match self.repo(&a.repo, &a.viewer).await? { | |
| 444 | Outcome::Ok(repo) => repo, | |
| 445 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 446 | }; | |
| 447 | let workspace = repo.namespace.to_lowercase(); | |
| 448 | // Found for the viewer, so they can read the project and its | |
| 449 | // memory; the workspace's is its members'. | |
| 450 | let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)); | |
| 451 | let words: Vec<String> = a | |
| 452 | .query | |
| 453 | .as_deref() | |
| 454 | .unwrap_or_default() | |
| 455 | .split_whitespace() | |
| 456 | .map(str::to_lowercase) | |
| 457 | .collect(); | |
| 458 | let limit = a.limit.unwrap_or(DEFAULT_RECALL).clamp(1, 100) as usize; | |
| 459 | let matching = |memories: Vec<Memory>| -> Vec<Memory> { | |
| 460 | memories | |
| 461 | .into_iter() | |
| 462 | .filter(|memory| { | |
| 463 | let text = memory.text.to_lowercase(); | |
| 464 | words.iter().all(|word| text.contains(word.as_str())) | |
| 465 | }) | |
| 466 | .take(limit) | |
| 467 | .collect() | |
| 468 | }; | |
| 469 | let found = Memories { | |
| 470 | project: matching(self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?), | |
| 471 | workspace: if member { | |
| 472 | matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?) | |
| 473 | } else { | |
| 474 | Vec::new() | |
| 475 | }, | |
| 476 | }; | |
| 477 | let ids: Vec<String> = found | |
| 478 | .project | |
| 479 | .iter() | |
| 480 | .chain(&found.workspace) | |
| 481 | .map(|memory| memory.id.clone()) | |
| 482 | .collect(); | |
| 483 | self.mark_used(&ids).await?; | |
| 484 | Ok(Outcome::Ok(found)) | |
| 485 | } | |
| 486 | } | |
| 487 | ||
| 488 | #[cfg(test)] | |
| 489 | mod tests { | |
| 490 | use super::*; | |
| 491 | ||
| 492 | #[test] | |
| 493 | fn transfer_and_deletion_statements_take_what_they_name() { | |
| 494 | for sql in TRANSFERRED.iter().chain(crate::guardrails::TRANSFERRED) { | |
| 495 | let n = g1t_kit::transfer::parameters(sql); | |
| 496 | assert!((1..=5).contains(&n), "{sql}"); | |
| 497 | } | |
| 498 | for sql in DELETED { | |
| 499 | assert_eq!(g1t_kit::rename::parameters(sql), 1, "{sql}"); | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | #[test] | |
| 504 | fn rename_statements_take_the_two_slugs() { | |
| 505 | for sql in RENAMED { | |
| 506 | assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}"); | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | #[test] | |
| 511 | fn secrets_are_refused_with_a_way_out() { | |
| 512 | // Joined at run time, so no whole key sits in the source for scanners to flag. | |
| 513 | let key = ["ghp", "_", "abcdefghijklmnopqrstuvwxyz0123456789"].concat(); | |
| 514 | let refused = valid_text(&format!("deploy with {key}")).unwrap_err(); | |
| 515 | assert!(refused.contains("never holds secrets")); | |
| 516 | assert_eq!(valid_text(" We use pnpm. ").unwrap(), "We use pnpm."); | |
| 517 | assert!(valid_text(" ").is_err()); | |
| 518 | } | |
| 519 | ||
| 520 | fn person(base: Option<access::BasePermission>, grants: &[(&str, access::RepoRole)], member: bool) -> User { | |
| 521 | User { | |
| 522 | id: "usr_1".into(), | |
| 523 | username: "ana".into(), | |
| 524 | verified: true, | |
| 525 | workspaces: if member { | |
| 526 | vec![g1t_contracts::Membership { base_permission: base, ..g1t_contracts::Membership::member("acme") }] | |
| 527 | } else { | |
| 528 | Vec::new() | |
| 529 | }, | |
| 530 | grants: grants | |
| 531 | .iter() | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 532 | .map(|(id, role)| access::RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role, team: None }) |
| Compare diffs from where the two branches last agreed, as a pull request would | 533 | .collect(), |
| 534 | ..User::default() | |
| 535 | } | |
| 536 | } | |
| 537 | ||
| 538 | #[test] | |
| 539 | fn a_workspaces_memory_is_its_members_and_a_projects_needs_write() { | |
| 540 | use access::{BasePermission, RepoRole}; | |
| 541 | let member = person(None, &[], true); | |
| 542 | assert!(may_write(&member, "acme", None)); | |
| 543 | assert!(may_write(&member, "acme", Some("rep_1"))); | |
| 544 | // A reader may not change a project's memory, nor may an outsider. | |
| 545 | let reader = person(Some(BasePermission::Read), &[], true); | |
| 546 | assert!(may_write(&reader, "acme", None)); | |
| 547 | assert!(!may_write(&reader, "acme", Some("rep_1"))); | |
| 548 | let outsider = person(None, &[("rep_1", RepoRole::Write)], false); | |
| 549 | assert!(may_write(&outsider, "acme", Some("rep_1"))); | |
| 550 | assert!(!may_write(&outsider, "acme", Some("rep_2"))); | |
| 551 | assert!(!may_write(&outsider, "acme", None)); | |
| 552 | let unverified = User { verified: false, ..person(None, &[], true) }; | |
| 553 | assert!(!may_write(&unverified, "acme", None)); | |
| 554 | } | |
| 555 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.