flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/work/src/memory.rs

733 lines30,758 bytesCodeBlame
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
8//! by whoever can push to it. It never holds a secret: text that looks like a key or a token is refused. Every
9//! g1t agent run is given it (`memory_context`): pinned first, then what
10//! was used most recently, within a size budget.
11
12use g1t_contracts::access::{self, Capability};
13use g1t_contracts::agents::*;
14use g1t_contracts::repos::{Repo, RepoPath};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id};
17use g1t_kit::now_ms;
18use serde::Deserialize;
19use worker::Result;
20use worker::wasm_bindgen::JsValue;
21
22use crate::Work;
23use crate::runs::member_of;
24
25/// A workspace renamed: its runs and memories move to the slug it has now.
26/// `?1` is the current slug, `?2` a stale one (see `g1t_kit::rename`).
27pub(crate) const RENAMED: &[&str] = &[
28 "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2",
29 "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
30 "UPDATE memories SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
31 "UPDATE memories SET workspace = ?1 WHERE workspace = ?2",
32 "UPDATE memories SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
33 "UPDATE memories SET source_repo = ?1 || substr(source_repo, length(?2) + 1) WHERE substr(source_repo, 1, length(?2) + 1) = ?2 || '/'",
34];
35
36/// A repository transferred (see `g1t_kit::transfer`): its agent runs and
37/// its project's memory go with it, as its issues and pull requests do by
38/// id. `?1`/`?2` the path now and before, `?3`/`?4` the workspaces, `?5`
39/// the repository's id.
40pub(crate) const TRANSFERRED: &[&str] = &[
41 "UPDATE agent_runs SET workspace = ?3, repo = ?1 WHERE repo_id = ?5 AND repo = ?2",
42 "UPDATE memories SET workspace = ?3, repo = ?1 WHERE scope = 'project' AND scope_key = ?5 AND workspace = ?4",
43 "UPDATE memories SET source_repo = ?1 WHERE source_repo = ?2",
44];
45
46/// A workspace deleted: its own memory, guardrails and queued runs go.
47/// `?1` is its slug. Project memory went with each repository's transfer.
48pub(crate) const DELETED: &[&str] = &[
49 "DELETE FROM memories WHERE scope = 'workspace' AND scope_key = ?1",
50 "DELETE FROM guardrails WHERE scope = 'workspace' AND scope_key = ?1",
51 "DELETE FROM agent_waits WHERE workspace = ?1",
52];
53
54/// The most memories one project, or one workspace, keeps.
55const MAX_PER_SCOPE: u32 = 500;
56/// What an agent is given by default, in characters.
57const DEFAULT_BUDGET: u32 = 6000;
58const MAX_BUDGET: u32 = 20_000;
59const DEFAULT_RECALL: u32 = 20;
60
61#[derive(Deserialize)]
62pub(crate) struct MemoryRow {
63 id: String,
64 pub(crate) scope: String,
65 pub(crate) scope_key: String,
66 workspace: String,
67 repo: Option<String>,
68 text: String,
69 kind: String,
70 source_kind: String,
71 source_run: Option<String>,
72 source_repo: Option<String>,
73 source_number: Option<u32>,
74 created_by: String,
75 pinned: u32,
76 created_at: String,
77 updated_at: String,
78 last_used_at: Option<String>,
79 // What capture adds (capture.rs, migration 0019).
80 #[serde(default)]
81 status: Option<String>,
82 #[serde(default)]
83 confidence: Option<f64>,
84 #[serde(default)]
85 source_ref: Option<String>,
86 #[serde(default)]
87 evidence: Option<String>,
88 #[serde(default)]
89 sources: Option<String>,
90}
91
92fn path(text: &str) -> Option<RepoPath> {
93 let (namespace, name) = text.split_once('/')?;
94 Some(RepoPath {
95 namespace: namespace.to_owned(),
96 name: name.to_owned(),
97 })
98}
99
100impl From<MemoryRow> for Memory {
101 fn from(row: MemoryRow) -> Self {
102 Memory {
103 id: row.id,
104 scope: if row.scope == "workspace" {
105 MemoryScope::Workspace
106 } else {
107 MemoryScope::Project
108 },
109 workspace: row.workspace,
110 repo: row.repo.as_deref().and_then(path),
111 text: row.text,
112 kind: MemoryKind::parse(&row.kind).unwrap_or_default(),
113 source: MemorySource {
114 kind: row.source_kind,
115 run_id: row.source_run,
116 repo: row.source_repo.as_deref().and_then(path),
117 number: row.source_number,
118 reference: row.source_ref,
119 evidence: row.evidence,
120 },
121 created_by: row.created_by,
122 pinned: row.pinned != 0,
123 created_at: row.created_at,
124 updated_at: row.updated_at,
125 last_used_at: row.last_used_at,
126 status: row.status.as_deref().and_then(MemoryStatus::parse).unwrap_or_default(),
127 confidence: row.confidence,
128 seen: row
129 .sources
130 .as_deref()
131 .and_then(|sources| serde_json::from_str::<Vec<String>>(sources).ok())
132 .map_or(1, |sources| sources.len().max(1) as u32),
133 }
134 }
135}
136
137/// The text tidied, or why it cannot be kept.
138fn valid_text(text: &str) -> std::result::Result<String, String> {
139 let text = text.trim();
140 if text.is_empty() {
141 return Err("Write what should be remembered.".into());
142 }
143 if text.chars().count() > MAX_MEMORY_CHARS {
144 return Err(format!(
145 "Keep a memory to {MAX_MEMORY_CHARS} characters: one fact, convention, decision or gotcha each."
146 ));
147 }
148 if let Some(what) = secret_in(text) {
149 return Err(format!(
150 "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\"."
151 ));
152 }
153 Ok(text.to_owned())
154}
155
156/// Why a person cannot change a memory.
157const CHANGE_DENIED: &str = "Only members of the workspace can change its memory, and a project's only with the Write role on it.";
158
159fn denied<T>() -> Outcome<T> {
160 Outcome::fail(
161 FailureCode::Forbidden,
162 "Memory is for members of the workspace and its agents.",
163 )
164}
165
166/// Whether `actor` may change a memory of `workspace`: the workspace's own
167/// needs a member; a project's (`repo_id`, its repository) the Write role
168/// on that repository, as pushing to it does.
169fn may_write(actor: &User, workspace: &str, repo_id: Option<&str>) -> bool {
170 let able = actor.verified || actor.kind != PrincipalKind::User;
171 able && match repo_id {
172 None => actor.is_member(workspace),
173 // Write is never had through being public, so treating it as
174 // private changes nothing.
175 Some(id) => access::can(
176 Some(actor),
177 access::RepoRef { id, namespace: workspace, private: true },
178 Capability::Push,
179 ),
180 }
181}
182
183/// The order memories are given and listed in: pinned, then most recently
184/// used or changed.
185const ORDER: &str = "pinned DESC, COALESCE(last_used_at, updated_at) DESC, created_at DESC";
186
187/// What an agent is told about memory, ahead of the memories themselves.
188const PREAMBLE: &str = "What people and agents have learned here before you, kept as memory. Treat it as notes from colleagues: usually right, sometimes out of date. Where it disagrees with the code, the code wins; say so in your summary.";
189
190/// How an agent is told to add to memory.
191const HOW_TO_REMEMBER: &str = "When you learn something the next agent here would need (how to build or test, a convention, a decision and why, a trap), save it with the remember tool: scope project for this codebase, workspace for what holds across the workspace's projects. One short fact each. Never a secret, a key or a token. recall searches what is kept.";
192
193/// The context an agent gets: as many memories as fit in `budget`, each
194/// level labelled, and the ids of those given.
195fn compose(workspace: &[Memory], project: &[Memory], repo: &RepoPath, budget: usize) -> (Option<String>, Vec<String>) {
196 let line = |memory: &Memory| {
197 format!(
198 "- [{}{}] {}",
199 memory.kind.as_str(),
200 if memory.pinned { ", pinned" } else { "" },
201 memory.text.replace('\n', " ")
202 )
203 };
204 let mut used = PREAMBLE.len() + HOW_TO_REMEMBER.len() + 200;
205 let mut given = Vec::new();
206 let mut sections = Vec::new();
207 // Pinned memories of either level go in before anything else does.
208 let mut take = |memories: &[Memory], pinned: bool, out: &mut Vec<String>| {
209 for memory in memories.iter().filter(|memory| memory.pinned == pinned) {
210 let text = line(memory);
211 if used + text.len() + 1 > budget {
212 continue;
213 }
214 used += text.len() + 1;
215 given.push(memory.id.clone());
216 out.push(text);
217 }
218 };
219 let (mut ws, mut own) = (Vec::new(), Vec::new());
220 take(workspace, true, &mut ws);
221 take(project, true, &mut own);
222 take(project, false, &mut own);
223 take(workspace, false, &mut ws);
224 if ws.is_empty() && own.is_empty() {
225 return (None, given);
226 }
227 sections.push(PREAMBLE.to_owned());
228 if !ws.is_empty() {
229 sections.push(format!(
230 "Workspace memory (true across the {} workspace's projects):\n{}",
231 repo.namespace,
232 ws.join("\n")
233 ));
234 }
235 if !own.is_empty() {
236 sections.push(format!(
237 "Project memory ({}/{}):\n{}",
238 repo.namespace,
239 repo.name,
240 own.join("\n")
241 ));
242 }
243 sections.push(HOW_TO_REMEMBER.to_owned());
244 (Some(sections.join("\n\n")), given)
245}
246
247impl Work {
248 async fn memories_of(&self, scope: MemoryScope, key: &str, limit: u32) -> Result<Vec<Memory>> {
249 Ok(self
250 .db
251 .prepare(format!(
252 // Only what is kept: candidates wait for review (capture.rs).
253 "SELECT * FROM memories WHERE scope = ? AND scope_key = ? AND status = 'kept' ORDER BY {ORDER} LIMIT ?"
254 ))
255 .bind(&[scope.as_str().into(), key.into(), limit.into()])?
256 .all()
257 .await?
258 .results::<MemoryRow>()?
259 .into_iter()
260 .map(Memory::from)
261 .collect())
262 }
263
264 async fn memory_row(&self, workspace: &str, id: &str) -> Result<Option<Memory>> {
265 Ok(self
266 .db
267 .prepare("SELECT * FROM memories WHERE id = ? AND workspace = ?")
268 .bind(&[id.into(), workspace.into()])?
269 .first::<MemoryRow>(None)
270 .await?
271 .map(Memory::from))
272 }
273
274 /// The repository a project's memory is about; none for the workspace's.
275 async fn memory_repo_id(&self, id: &str) -> Result<Option<String>> {
276 Ok(self
277 .db
278 .prepare("SELECT scope_key AS value FROM memories WHERE id = ? AND scope = 'project'")
279 .bind(&[id.into()])?
280 .first::<String>(Some("value"))
281 .await?)
282 }
283
284 async fn mark_used(&self, ids: &[String]) -> Result<()> {
285 if ids.is_empty() {
286 return Ok(());
287 }
288 self.db
289 .prepare("UPDATE memories SET last_used_at = ? WHERE id IN (SELECT value FROM json_each(?))")
290 .bind(&[rfc3339(now_ms()).into(), serde_json::to_string(ids)?.into()])?
291 .run()
292 .await?;
293 Ok(())
294 }
295
296 /// The project's repository, which must be in `workspace`.
297 async fn project_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
298 Ok(match self.repo(path, viewer).await? {
299 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
300 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
301 Outcome::Fail(failure) => Outcome::Fail(failure),
302 })
303 }
304
305 pub(crate) async fn list_memories(&self, a: ListMemoriesArgs) -> Result<Outcome<Memories>> {
306 let workspace = a.workspace.to_lowercase();
307 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
308 // Someone else who can read the project sees its memory alone.
309 if !member && a.repo.is_none() {
310 return Ok(denied());
311 }
312 let project = match &a.repo {
313 Some(path) => match self.project_repo(path, &a.viewer, &workspace).await? {
314 Outcome::Ok(repo) => self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?,
315 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
316 },
317 None => Vec::new(),
318 };
319 let workspace = if member {
320 self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?
321 } else {
322 Vec::new()
323 };
324 Ok(Outcome::Ok(Memories { project, workspace }))
325 }
326
327 pub(crate) async fn add_memory(&self, a: AddMemoryArgs) -> Result<Outcome<Memory>> {
328 let workspace = a.workspace.to_lowercase();
329 if a.scope == MemoryScope::Workspace && !may_write(&a.actor, &workspace, None) {
330 return Ok(denied());
331 }
332 let text = match valid_text(&a.text) {
333 Ok(text) => text,
334 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
335 };
336 let viewer = Some(a.actor.clone());
337 let repo = match &a.repo {
338 Some(path) => match self.project_repo(path, &viewer, &workspace).await? {
339 Outcome::Ok(repo) => Some(repo),
340 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
341 },
342 None => None,
343 };
344 let key = match (a.scope, &repo) {
345 (MemoryScope::Workspace, _) => workspace.clone(),
346 (MemoryScope::Project, Some(repo)) => {
347 if !may_write(&a.actor, &workspace, Some(&repo.id)) {
348 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
349 }
350 repo.id.clone()
351 }
352 (MemoryScope::Project, None) => {
353 return Ok(Outcome::fail(FailureCode::Invalid, "Name the project this memory is about."));
354 }
355 };
356 // The same thing remembered twice is one memory, freshened; written
357 // by hand, a candidate or a dismissed one with the same words is
358 // kept.
359 let now = rfc3339(now_ms());
360 let print = g1t_contracts::capture::fingerprint(&text);
361 let same = self
362 .db
363 .prepare(
364 "UPDATE memories SET updated_at = ?, status = 'kept'
365 WHERE scope = ? AND scope_key = ? AND (text = ? OR fingerprint = ?) RETURNING id AS value",
366 )
367 .bind(&[now.as_str().into(), a.scope.as_str().into(), key.as_str().into(), text.as_str().into(), print.as_str().into()])?
368 .first::<String>(Some("value"))
369 .await?;
370 if let Some(id) = same {
371 let repo_id = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
372 self.memory_changed(&id, &workspace, "kept", repo_id.as_deref()).await;
373 return Ok(match self.memory_row(&workspace, &id).await? {
374 Some(memory) => Outcome::Ok(memory),
375 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
376 });
377 }
378 let count = self
379 .db
380 .prepare("SELECT count(*) AS value FROM memories WHERE scope = ? AND scope_key = ? AND status != 'dismissed'")
381 .bind(&[a.scope.as_str().into(), key.as_str().into()])?
382 .first::<u32>(Some("value"))
383 .await?
384 .unwrap_or_default();
385 if count >= MAX_PER_SCOPE {
386 return Ok(Outcome::fail(
387 FailureCode::Conflict,
388 format!("This {} already keeps {MAX_PER_SCOPE} memories. Remove some that no longer hold first.", a.scope.as_str()),
389 ));
390 }
391 // Where it came from: a person, or an agent, and the run it was in.
392 let from = match (&repo, a.from_number) {
393 (Some(repo), Some(number)) => Some((repo, number)),
394 _ => None,
395 };
396 let run = match (a.actor.kind, from) {
397 (PrincipalKind::Agent, Some((repo, number))) => self.active_run_on(&repo.id, number).await?,
398 _ => None,
399 };
400 let source_kind = match (a.actor.kind, &run) {
401 (PrincipalKind::User, _) => "person",
402 (_, Some(_)) => "run",
403 _ => "agent",
404 };
405 let id = new_id("mem", now_ms());
406 let repo_text = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
407 // Its source, as capture counts sources (capture.rs).
408 let reference = match &run {
409 Some(run) => format!("run:{run}"),
410 None => format!("{source_kind}:{}", a.actor.username),
411 };
412 let changed_repo = repo.as_ref().filter(|_| a.scope == MemoryScope::Project).map(|repo| repo.id.clone());
413 self.db
414 .prepare(
415 "INSERT INTO memories
416 (id, scope, scope_key, workspace, repo, text, kind, source_kind, source_run,
417 source_repo, source_number, created_by, pinned, created_at, updated_at,
418 status, fingerprint, sources, source_ref)
419 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'kept', ?, ?, ?)",
420 )
421 .bind(&[
422 id.as_str().into(),
423 a.scope.as_str().into(),
424 key.clone().into(),
425 workspace.as_str().into(),
426 // A workspace's memory belongs to no one project, though it
427 // remembers which it was learned in.
428 if a.scope == MemoryScope::Project { repo_text.clone().map_or(JsValue::NULL, JsValue::from) } else { JsValue::NULL },
429 text.into(),
430 a.kind.as_str().into(),
431 source_kind.into(),
432 run.map_or(JsValue::NULL, JsValue::from),
433 repo_text.map_or(JsValue::NULL, JsValue::from),
434 from.map_or(JsValue::NULL, |(_, number)| number.into()),
435 a.actor.username.as_str().into(),
436 u32::from(a.pinned).into(),
437 now.as_str().into(),
438 now.as_str().into(),
439 print.as_str().into(),
440 serde_json::to_string(&[&reference])?.into(),
441 reference.as_str().into(),
442 ])?
443 .run()
444 .await?;
445 self.memory_changed(&id, &workspace, "kept", changed_repo.as_deref()).await;
446 Ok(match self.memory_row(&workspace, &id).await? {
447 Some(memory) => Outcome::Ok(memory),
448 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
449 })
450 }
451
452 pub(crate) async fn update_memory(&self, a: UpdateMemoryArgs) -> Result<Outcome<Memory>> {
453 let workspace = a.workspace.to_lowercase();
454 let repo_id = self.memory_repo_id(&a.id).await?;
455 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
456 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
457 }
458 let Some(before) = self.memory_row(&workspace, &a.id).await? else {
459 return Ok(Outcome::fail(FailureCode::NotFound, "Memory not found."));
460 };
461 let text = match a.text.as_deref().map(valid_text) {
462 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
463 Some(Ok(text)) => Some(text),
464 None => None,
465 };
466 let print = text.as_deref().map(g1t_contracts::capture::fingerprint);
467 self.db
468 .prepare(
469 "UPDATE memories SET text = COALESCE(?, text), kind = COALESCE(?, kind),
470 pinned = COALESCE(?, pinned), fingerprint = COALESCE(?, fingerprint), updated_at = ?
471 WHERE id = ? AND workspace = ?",
472 )
473 .bind(&[
474 text.map_or(JsValue::NULL, JsValue::from),
475 a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
476 a.pinned.map_or(JsValue::NULL, |pinned| u32::from(pinned).into()),
477 print.map_or(JsValue::NULL, JsValue::from),
478 rfc3339(now_ms()).into(),
479 a.id.as_str().into(),
480 workspace.as_str().into(),
481 ])?
482 .run()
483 .await?;
484 self.memory_changed(&a.id, &workspace, before.status.as_str(), repo_id.as_deref()).await;
485 Ok(match self.memory_row(&workspace, &a.id).await? {
486 Some(memory) => Outcome::Ok(memory),
487 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
488 })
489 }
490
491 pub(crate) async fn delete_memory(&self, a: DeleteMemoryArgs) -> Result<Outcome<bool>> {
492 let workspace = a.workspace.to_lowercase();
493 let repo_id = self.memory_repo_id(&a.id).await?;
494 if !may_write(&a.actor, &workspace, repo_id.as_deref()) || a.actor.kind == PrincipalKind::Agent {
495 return Ok(Outcome::fail(FailureCode::Forbidden, CHANGE_DENIED));
496 }
497 let gone = self
498 .db
499 .prepare("DELETE FROM memories WHERE id = ? AND workspace = ? RETURNING id AS value")
500 .bind(&[a.id.as_str().into(), workspace.as_str().into()])?
501 .first::<String>(Some("value"))
502 .await?;
503 Ok(match gone {
504 Some(_) => {
505 self.memory_changed(&a.id, &workspace, "deleted", repo_id.as_deref()).await;
506 Outcome::Ok(true)
507 }
508 None => Outcome::fail(FailureCode::NotFound, "Memory not found."),
509 })
510 }
511
512 pub(crate) async fn recall(&self, a: RecallArgs) -> Result<Outcome<Memories>> {
513 let repo = match self.repo(&a.repo, &a.viewer).await? {
514 Outcome::Ok(repo) => repo,
515 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
516 };
517 let workspace = repo.namespace.to_lowercase();
518 // Found for the viewer, so they can read the project and its
519 // memory; the workspace's is its members'.
520 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
521 let words: Vec<String> = a
522 .query
523 .as_deref()
524 .unwrap_or_default()
525 .split_whitespace()
526 .map(str::to_lowercase)
527 .collect();
528 let limit = a.limit.unwrap_or(DEFAULT_RECALL).clamp(1, 100) as usize;
529 let matching = |memories: Vec<Memory>| -> Vec<Memory> {
530 memories
531 .into_iter()
532 .filter(|memory| {
533 let text = memory.text.to_lowercase();
534 words.iter().all(|word| text.contains(word.as_str()))
535 })
536 .take(limit)
537 .collect()
538 };
539 let found = Memories {
540 project: matching(self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?),
541 workspace: if member {
542 matching(self.memories_of(MemoryScope::Workspace, &workspace, MAX_PER_SCOPE).await?)
543 } else {
544 Vec::new()
545 },
546 };
547 let ids: Vec<String> = found
548 .project
549 .iter()
550 .chain(&found.workspace)
551 .map(|memory| memory.id.clone())
552 .collect();
553 self.mark_used(&ids).await?;
554 Ok(Outcome::Ok(found))
555 }
556
557 pub(crate) async fn memory_context(&self, a: MemoryContextArgs) -> Result<MemoryContext> {
558 let service = User {
559 id: "g1t_runner".into(),
560 username: "g1t".into(),
561 ..User::default()
562 };
563 let Outcome::Ok(repo) = self.repo(&a.repo, &member_of(&service, &a.repo.namespace)).await? else {
564 return Ok(MemoryContext::default());
565 };
566 // The workspace's memory is its members': a run for an outside
567 // collaborator is told only the project's.
568 let workspace = if workspace_memory_for(a.requester.as_ref(), &repo.namespace) {
569 self.memories_of(MemoryScope::Workspace, &repo.namespace.to_lowercase(), MAX_PER_SCOPE)
570 .await?
571 } else {
572 Vec::new()
573 };
574 let project = self.memories_of(MemoryScope::Project, &repo.id, MAX_PER_SCOPE).await?;
575 let budget = a.budget.unwrap_or(DEFAULT_BUDGET).clamp(500, MAX_BUDGET) as usize;
576 let path = RepoPath {
577 namespace: repo.namespace.clone(),
578 name: repo.name.clone(),
579 };
580 let (text, given) = compose(&workspace, &project, &path, budget);
581 self.mark_used(&given).await?;
582 Ok(MemoryContext {
583 text,
584 count: given.len() as u32,
585 })
586 }
587}
588
589/// Whether a run for `requester` in `namespace` is told the workspace's
590/// memory: for its members, and for the workspace's own steps (no
591/// requester), never for an outside collaborator.
592fn workspace_memory_for(requester: Option<&User>, namespace: &str) -> bool {
593 requester.is_none_or(|user| user.is_member(&namespace.to_lowercase()))
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn workspace_memory_is_for_members_runs_only() {
602 let member = User {
603 id: "u_ana".into(),
604 username: "ana".into(),
605 workspaces: vec![g1t_contracts::Membership::member("acme")],
606 ..User::default()
607 };
608 let outside = User {
609 id: "u_oli".into(),
610 username: "oli".into(),
611 ..User::default()
612 };
613 assert!(workspace_memory_for(None, "acme"));
614 assert!(workspace_memory_for(Some(&member), "Acme"));
615 assert!(!workspace_memory_for(Some(&outside), "acme"));
616 }
617
618 #[test]
619 fn transfer_and_deletion_statements_take_what_they_name() {
620 for sql in TRANSFERRED.iter().chain(crate::guardrails::TRANSFERRED) {
621 let n = g1t_kit::transfer::parameters(sql);
622 assert!((1..=5).contains(&n), "{sql}");
623 }
624 for sql in DELETED {
625 assert_eq!(g1t_kit::rename::parameters(sql), 1, "{sql}");
626 }
627 }
628
629 fn memory(id: &str, text: &str, pinned: bool) -> Memory {
630 Memory {
631 id: id.into(),
632 scope: MemoryScope::Project,
633 workspace: "acme".into(),
634 repo: None,
635 text: text.into(),
636 kind: MemoryKind::Fact,
637 source: MemorySource::default(),
638 created_by: "ana".into(),
639 pinned,
640 created_at: String::new(),
641 updated_at: String::new(),
642 last_used_at: None,
643 status: MemoryStatus::Kept,
644 confidence: None,
645 seen: 1,
646 }
647 }
648
649 fn repo() -> RepoPath {
650 RepoPath {
651 namespace: "acme".into(),
652 name: "web".into(),
653 }
654 }
655
656 #[test]
657 fn rename_statements_take_the_two_slugs() {
658 for sql in RENAMED {
659 assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}");
660 }
661 }
662
663 #[test]
664 fn nothing_kept_is_nothing_said() {
665 assert_eq!(compose(&[], &[], &repo(), 6000), (None, Vec::new()));
666 }
667
668 #[test]
669 fn levels_are_labelled_and_pinned_come_first() {
670 let workspace = [memory("w1", "We use pnpm everywhere.", false)];
671 let project = [memory("p1", "Tests need TZ=UTC.", false), memory("p2", "Never edit generated.rs.", true)];
672 let (text, given) = compose(&workspace, &project, &repo(), 6000);
673 let text = text.unwrap();
674 assert!(text.contains("Workspace memory (true across the acme workspace's projects)"));
675 assert!(text.contains("Project memory (acme/web)"));
676 assert!(text.find("Never edit").unwrap() < text.find("Tests need").unwrap());
677 assert_eq!(given, ["p2", "p1", "w1"]);
678 }
679
680 #[test]
681 fn the_budget_is_kept() {
682 let project: Vec<Memory> = (0..100).map(|n| memory(&format!("p{n}"), &"x".repeat(200), false)).collect();
683 let (text, given) = compose(&[], &project, &repo(), 3000);
684 assert!(text.unwrap().len() <= 3000);
685 assert!(given.len() < 100 && !given.is_empty());
686 }
687
688 #[test]
689 fn secrets_are_refused_with_a_way_out() {
690 // Joined at run time, so no whole key sits in the source for scanners to flag.
691 let key = ["ghp", "_", "abcdefghijklmnopqrstuvwxyz0123456789"].concat();
692 let refused = valid_text(&format!("deploy with {key}")).unwrap_err();
693 assert!(refused.contains("never holds secrets"));
694 assert_eq!(valid_text(" We use pnpm. ").unwrap(), "We use pnpm.");
695 assert!(valid_text(" ").is_err());
696 }
697
698 fn person(base: Option<access::BasePermission>, grants: &[(&str, access::RepoRole)], member: bool) -> User {
699 User {
700 id: "usr_1".into(),
701 username: "ana".into(),
702 verified: true,
703 workspaces: if member {
704 vec![g1t_contracts::Membership { base_permission: base, ..g1t_contracts::Membership::member("acme") }]
705 } else {
706 Vec::new()
707 },
708 grants: grants
709 .iter()
710 .map(|(id, role)| access::RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role })
711 .collect(),
712 ..User::default()
713 }
714 }
715
716 #[test]
717 fn a_workspaces_memory_is_its_members_and_a_projects_needs_write() {
718 use access::{BasePermission, RepoRole};
719 let member = person(None, &[], true);
720 assert!(may_write(&member, "acme", None));
721 assert!(may_write(&member, "acme", Some("rep_1")));
722 // A reader may not change a project's memory, nor may an outsider.
723 let reader = person(Some(BasePermission::Read), &[], true);
724 assert!(may_write(&reader, "acme", None));
725 assert!(!may_write(&reader, "acme", Some("rep_1")));
726 let outsider = person(None, &[("rep_1", RepoRole::Write)], false);
727 assert!(may_write(&outsider, "acme", Some("rep_1")));
728 assert!(!may_write(&outsider, "acme", Some("rep_2")));
729 assert!(!may_write(&outsider, "acme", None));
730 let unverified = User { verified: false, ..person(None, &[], true) };
731 assert!(!may_write(&unverified, "acme", None));
732 }
733}