Agents and memory, checks and conflicts, profiles, slug renames, custom domains
Agents: every agent run is a record (kind, pull request, model, status, current step and a timeline of steps reported from the sandbox, cost), with Stop and Message. Projects get an Agents page (At work, Sessions, Memory), workspaces an Agent fleet and Workspace memory, and pull requests an Agent panel and a working badge in the list. Memory has two levels, project and workspace; agents write it with remember and read it with recall (MCP and REST), every g1t run starts with it, members curate it, and anything that looks like a secret is refused. Pull requests: the merge box lists every check and workflow job, failed ones open with their output (colours, line numbers, exit code), with re-run and history. Mergeability is worked out on every push: changed paths first, then a metered sandbox probe with git merge-tree when both sides touched a file. Conflicts are listed up front, block the merge, and are handed to the agent with the files named. Profiles at /u/<username>: name, picture, bio, location, website, joined; pull requests and issues with filters, only on repositories the viewer may read; workspaces shown only where membership is already shared or public. Authors link to their profiles; profiles get social cards. Workspace slugs can be renamed by owners. Every service moves its rows on workspace.renamed, idempotently and in any order; deployed apps are rebuilt under their new names with the old addresses redirecting; old links and git remotes redirect for 90 days. Repositories keep a store key that never changes, so a freed name can never adopt old git data. Custom domains for production: add a hostname (and its www or apex twin), get the DNS records to set, certificates by Cloudflare for SaaS, status kept current by the sweep; three included with Deployments, then $0.12 a month. The wildcard route waits for SaaS to be enabled on g1t.page. The sidebar no longer mistakes /u/<name> for a project.
| 9 | 9 | mod oauth; | |
| 10 | 10 | mod openapi; | |
| 11 | 11 | mod operations; | |
| 12 | + | mod renamed; | |
| 12 | 13 | mod rest; | |
| 13 | 14 | ||
| 14 | 15 | use g1t_contracts::billing::FinishRunArgs; | |
| 16 | 17 | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, | |
| 17 | 18 | }; | |
| 18 | 19 | use g1t_contracts::work::{ | |
| 19 | − | CheckRun, QueueState, ReportChecksArgs, ReportPlanArgs, ReportQueueArgs, ReportReviewArgs, | |
| 20 | + | CheckRun, Mergeable, QueueState, ReportChecksArgs, ReportMergecheckArgs, ReportPlanArgs, | |
| 21 | + | ReportQueueArgs, ReportReviewArgs, | |
| 20 | 22 | }; | |
| 21 | 23 | use g1t_contracts::identity::AgentScope; | |
| 22 | 24 | use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, Viewer}; | |
| 257 | 259 | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), | |
| 258 | 260 | error: body["error"].as_str().map(str::to_owned), | |
| 259 | 261 | conflict_with: body["conflictWith"].as_u64().map(|n| n as u32), | |
| 262 | + | conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(), | |
| 260 | 263 | }, | |
| 261 | 264 | ) | |
| 262 | 265 | .await?; | |
| 266 | 269 | } | |
| 267 | 270 | } | |
| 268 | 271 | ||
| 272 | + | /// A sandbox reporting whether a pull request merges cleanly. As with | |
| 273 | + | /// checks, the probe's own token is the credential. | |
| 274 | + | async fn report_mergecheck( | |
| 275 | + | request: &mut Request, | |
| 276 | + | services: &Services, | |
| 277 | + | pull_id: &str, | |
| 278 | + | ) -> Result<Response> { | |
| 279 | + | let body = json_body(request).await; | |
| 280 | + | let reported: Outcome<Mergeable> = g1t_kit::call( | |
| 281 | + | &services.work, | |
| 282 | + | "report_mergecheck", | |
| 283 | + | &ReportMergecheckArgs { | |
| 284 | + | pull_id: pull_id.to_owned(), | |
| 285 | + | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 286 | + | conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(), | |
| 287 | + | error: body["error"].as_str().map(str::to_owned), | |
| 288 | + | }, | |
| 289 | + | ) | |
| 290 | + | .await?; | |
| 291 | + | match reported { | |
| 292 | + | Outcome::Ok(state) => Response::from_json(&json!({ "mergeable": state })), | |
| 293 | + | Outcome::Fail(refused) => failure(&refused), | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 269 | 297 | /// A sandbox reporting the review its agent wrote. As with checks, the | |
| 270 | 298 | /// run's own token is the credential. | |
| 271 | 299 | async fn report_review( | |
| 485 | 513 | Outcome::Fail(refused) => failure(&refused), | |
| 486 | 514 | }; | |
| 487 | 515 | } | |
| 516 | + | ("POST", path) if path.starts_with("/mergechecks/") => { | |
| 517 | + | let pull_id = path.trim_start_matches("/mergechecks/").to_owned(); | |
| 518 | + | return report_mergecheck(&mut request, &services, &pull_id).await; | |
| 519 | + | } | |
| 488 | 520 | ("POST", path) if path.starts_with("/queue/") => { | |
| 489 | 521 | let entry_id = path.trim_start_matches("/queue/").to_owned(); | |
| 490 | 522 | return report_queue(&mut request, &services, &entry_id).await; | |
| 509 | 541 | Outcome::Fail(refused) => failure(&refused), | |
| 510 | 542 | }; | |
| 511 | 543 | } | |
| 544 | + | // A sandbox reporting its agent run's steps, cost and end. As with | |
| 545 | + | // checks, the run's own token, in the body, is the credential. | |
| 546 | + | ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/report") => { | |
| 547 | + | let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/report"); | |
| 548 | + | let mut body = json_body(&mut request).await; | |
| 549 | + | if !body.is_object() { | |
| 550 | + | body = json!({}); | |
| 551 | + | } | |
| 552 | + | body["runId"] = json!(run_id); | |
| 553 | + | let reported: Outcome<Value> = g1t_kit::call(&services.work, "report_run", &body).await?; | |
| 554 | + | return match reported { | |
| 555 | + | Outcome::Ok(status) => Response::from_json(&json!({ "status": status })), | |
| 556 | + | Outcome::Fail(refused) => failure(&refused), | |
| 557 | + | }; | |
| 558 | + | } | |
| 512 | 559 | ("POST", path) if path.starts_with("/checks/") => { | |
| 513 | 560 | let run_id = path.trim_start_matches("/checks/").to_owned(); | |
| 514 | 561 | return report_checks(&mut request, &services, &run_id).await; |
| 13 | 13 | const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once. | |
| 14 | 14 | To work on an issue: get_issue to read it and see the pull requests already made for it, then create_pull_request with the issue's number. You get a draft pull request with its own fork to clone and push to. Call record_session as you work so people can see your reasoning, push your commits, and call mark_pull_request_ready with a summary. | |
| 15 | 15 | Before going far, read `overlaps` on get_pull_request: other pull requests in progress that change the same files. One for a different issue will conflict with yours, so narrow your change or say so. `behind` means main has moved; pull it into your fork and push. Once your pull request is ready, the issue's acceptance checks are run for you in a clean sandbox; read their output from get_pull_request and push a fix if they fail. | |
| 16 | + | Before you start, recall what the project and its workspace remember; when you learn something the next agent would need, remember it (scope project for this codebase, workspace for what holds across projects). Never a secret. | |
| 16 | 17 | Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers."; | |
| 17 | 18 | ||
| 18 | 19 | fn result(id: &Value, value: Value) -> Value { |
| 79 | 79 | &[Op::ReadSession, Op::RecordSession], | |
| 80 | 80 | ), | |
| 81 | 81 | ( | |
| 82 | + | "Memory", | |
| 83 | + | "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.", | |
| 84 | + | &[Op::Remember, Op::Recall], | |
| 85 | + | ), | |
| 86 | + | ( | |
| 82 | 87 | "Actions", | |
| 83 | 88 | "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.", | |
| 84 | 89 | &[ | |
| 156 | 161 | Op::MessageAgent => "Message an agent", | |
| 157 | 162 | Op::AnswerMessage => "Answer a message", | |
| 158 | 163 | Op::TakeMessages => "Take new messages", | |
| 164 | + | Op::Remember => "Remember something", | |
| 165 | + | Op::Recall => "Recall memory", | |
| 159 | 166 | Op::ListIssues => "List issues", | |
| 160 | 167 | Op::GetIssue => "Get an issue", | |
| 161 | 168 | Op::CreateIssue => "Create an issue", |
| 61 | 61 | MessageAgent, | |
| 62 | 62 | AnswerMessage, | |
| 63 | 63 | TakeMessages, | |
| 64 | + | Remember, | |
| 65 | + | Recall, | |
| 64 | 66 | ListIssues, | |
| 65 | 67 | GetIssue, | |
| 66 | 68 | CreateIssue, | |
| 285 | 287 | } | |
| 286 | 288 | ||
| 287 | 289 | impl Op { | |
| 288 | − | pub const ALL: [Op; 64] = [ | |
| 290 | + | pub const ALL: [Op; 66] = [ | |
| 289 | 291 | Op::Whoami, | |
| 290 | 292 | Op::CreateWorkspace, | |
| 291 | 293 | Op::ListRepos, | |
| 298 | 300 | Op::MessageAgent, | |
| 299 | 301 | Op::AnswerMessage, | |
| 300 | 302 | Op::TakeMessages, | |
| 303 | + | Op::Remember, | |
| 304 | + | Op::Recall, | |
| 301 | 305 | Op::ListIssues, | |
| 302 | 306 | Op::GetIssue, | |
| 303 | 307 | Op::CreateIssue, | |
| 370 | 374 | Op::MessageAgent => "message_agent", | |
| 371 | 375 | Op::AnswerMessage => "answer_message", | |
| 372 | 376 | Op::TakeMessages => "take_messages", | |
| 377 | + | Op::Remember => "remember", | |
| 378 | + | Op::Recall => "recall", | |
| 373 | 379 | Op::UpdateRepoSettings => "update_repo_settings", | |
| 374 | 380 | Op::ListIssues => "list_issues", | |
| 375 | 381 | Op::GetIssue => "get_issue", | |
| 451 | 457 | Op::AnswerMessage => { | |
| 452 | 458 | "Answer a question or a handoff another agent sent you, by the message's id. For a handoff, set decline to say it is not yours to take. The answer reaches the asking agent at its next step." | |
| 453 | 459 | } | |
| 460 | + | Op::Remember => { | |
| 461 | + | "Save something to memory that the next agent working here should know: how to build or test, a convention, a decision and why, a trap. scope project is for this codebase; scope workspace is for what holds across all of the workspace's projects, such as \"we use pnpm everywhere\" or where staging lives. One short fact per memory. Every g1t agent run is given memory at its start, pinned first. Never save a secret, key, token or password: text that looks like one is refused. Members of the workspace and g1t's agents only." | |
| 462 | + | } | |
| 463 | + | Op::Recall => { | |
| 464 | + | "Search what the project and its workspace remember, by words in any order, or list it all without a query. Pinned memories come first, then the most recently used. Members of the workspace and g1t's agents only." | |
| 465 | + | } | |
| 454 | 466 | Op::TakeMessages => { | |
| 455 | 467 | "For a g1t agent at work: the messages sent to it that it has not seen yet, from people and from other agents. Each is returned once." | |
| 456 | 468 | } | |
| 656 | 668 | &["repo", "id", "body"], | |
| 657 | 669 | ), | |
| 658 | 670 | Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]), | |
| 671 | + | Op::Remember => object( | |
| 672 | + | json!({ | |
| 673 | + | "repo": repo_schema(), | |
| 674 | + | "text": { "type": "string", "description": "What to remember, in one or two sentences. At most 1000 characters." }, | |
| 675 | + | "scope": { | |
| 676 | + | "type": "string", | |
| 677 | + | "enum": ["project", "workspace"], | |
| 678 | + | "description": "project: about this codebase. workspace: true across the workspace's projects. Defaults to project.", | |
| 679 | + | }, | |
| 680 | + | "kind": { | |
| 681 | + | "type": "string", | |
| 682 | + | "enum": ["fact", "convention", "decision", "gotcha"], | |
| 683 | + | "description": "Defaults to fact.", | |
| 684 | + | }, | |
| 685 | + | "from_number": { | |
| 686 | + | "type": "integer", | |
| 687 | + | "description": "For an agent: the pull request you are working on, recorded as where it was learned.", | |
| 688 | + | }, | |
| 689 | + | }), | |
| 690 | + | &["repo", "text"], | |
| 691 | + | ), | |
| 692 | + | Op::Recall => object( | |
| 693 | + | json!({ | |
| 694 | + | "repo": repo_schema(), | |
| 695 | + | "query": { "type": "string", "description": "Words to look for. Leave out for everything." }, | |
| 696 | + | "limit": { "type": "integer", "description": "At most 100 of each level; 20 if not given." }, | |
| 697 | + | }), | |
| 698 | + | &["repo"], | |
| 699 | + | ), | |
| 659 | 700 | Op::UpdateRepoSettings => object( | |
| 660 | 701 | json!({ | |
| 661 | 702 | "repo": repo_schema(), | |
| 1144 | 1185 | ) | |
| 1145 | 1186 | } | |
| 1146 | 1187 | ||
| 1188 | + | /// Runs the operation. One that found nothing, or was refused, under a | |
| 1189 | + | /// workspace slug that has since been renamed runs again under the | |
| 1190 | + | /// workspace's current slug; neither outcome changed anything. | |
| 1147 | 1191 | pub async fn run( | |
| 1148 | 1192 | self, | |
| 1149 | 1193 | services: &Services, | |
| 1150 | 1194 | viewer: &Viewer, | |
| 1151 | 1195 | input: &Value, | |
| 1152 | 1196 | ) -> Result<Outcome<Value>> { | |
| 1197 | + | let outcome = self.run_once(services, viewer, input).await?; | |
| 1198 | + | if let Outcome::Fail(failure) = &outcome | |
| 1199 | + | && matches!(failure.code, FailureCode::NotFound | FailureCode::Forbidden) | |
| 1200 | + | && let Some(retargeted) = crate::renamed::retarget(services, input).await? | |
| 1201 | + | { | |
| 1202 | + | return self.run_once(services, viewer, &retargeted).await; | |
| 1203 | + | } | |
| 1204 | + | Ok(outcome) | |
| 1205 | + | } | |
| 1206 | + | ||
| 1207 | + | async fn run_once( | |
| 1208 | + | self, | |
| 1209 | + | services: &Services, | |
| 1210 | + | viewer: &Viewer, | |
| 1211 | + | input: &Value, | |
| 1212 | + | ) -> Result<Outcome<Value>> { | |
| 1153 | 1213 | if self.needs_user() && viewer.is_none() { | |
| 1154 | 1214 | return failed( | |
| 1155 | 1215 | FailureCode::Unauthenticated, | |
| 1316 | 1376 | ) | |
| 1317 | 1377 | .await | |
| 1318 | 1378 | } | |
| 1379 | + | Op::Remember => { | |
| 1380 | + | let scope = match input["scope"].as_str() { | |
| 1381 | + | Some("workspace") => "workspace", | |
| 1382 | + | None | Some("project") => "project", | |
| 1383 | + | Some(_) => return failed(FailureCode::Invalid, "scope must be project or workspace."), | |
| 1384 | + | }; | |
| 1385 | + | let kind = input["kind"].as_str().unwrap_or("fact"); | |
| 1386 | + | if g1t_contracts::agents::MemoryKind::parse(kind).is_none() { | |
| 1387 | + | return failed(FailureCode::Invalid, "kind must be fact, convention, decision or gotcha."); | |
| 1388 | + | } | |
| 1389 | + | pass( | |
| 1390 | + | work, | |
| 1391 | + | "add_memory", | |
| 1392 | + | &json!({ | |
| 1393 | + | "actor": actor(), | |
| 1394 | + | "workspace": repo.namespace.to_lowercase(), | |
| 1395 | + | "repo": repo, | |
| 1396 | + | "scope": scope, | |
| 1397 | + | "text": text(input, "text"), | |
| 1398 | + | "kind": kind, | |
| 1399 | + | "fromNumber": integer(input, "from_number"), | |
| 1400 | + | }), | |
| 1401 | + | ) | |
| 1402 | + | .await | |
| 1403 | + | } | |
| 1404 | + | Op::Recall => { | |
| 1405 | + | pass( | |
| 1406 | + | work, | |
| 1407 | + | "recall", | |
| 1408 | + | &json!({ | |
| 1409 | + | "viewer": viewer, | |
| 1410 | + | "repo": repo, | |
| 1411 | + | "query": optional_text(input, "query"), | |
| 1412 | + | "limit": integer(input, "limit"), | |
| 1413 | + | }), | |
| 1414 | + | ) | |
| 1415 | + | .await | |
| 1416 | + | } | |
| 1319 | 1417 | Op::TakeMessages => { | |
| 1320 | 1418 | pass( | |
| 1321 | 1419 | work, |
| 979 | 979 | ], | |
| 980 | 980 | "statuses": [] | |
| 981 | 981 | }, | |
| 982 | − | "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves." | |
| 982 | + | "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlierChecks` | Earlier runs of the acceptance checks, newest first, without their output. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves." | |
| 983 | 983 | }, | |
| 984 | 984 | "get_pull_request_changes": { | |
| 985 | 985 | "response": { | |
| 2560 | 2560 | "created": true | |
| 2561 | 2561 | }, | |
| 2562 | 2562 | "notes": "`created` is false when the ticket was imported before; `number` is then that issue." | |
| 2563 | + | }, | |
| 2564 | + | "remember": { | |
| 2565 | + | "request": { | |
| 2566 | + | "text": "The tests need TZ=UTC or the date tests fail.", | |
| 2567 | + | "scope": "project", | |
| 2568 | + | "kind": "gotcha", | |
| 2569 | + | "from_number": 14 | |
| 2570 | + | }, | |
| 2571 | + | "response": { | |
| 2572 | + | "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p", | |
| 2573 | + | "scope": "project", | |
| 2574 | + | "workspace": "syntaqx", | |
| 2575 | + | "repo": { | |
| 2576 | + | "namespace": "syntaqx", | |
| 2577 | + | "name": "hello" | |
| 2578 | + | }, | |
| 2579 | + | "text": "The tests need TZ=UTC or the date tests fail.", | |
| 2580 | + | "kind": "gotcha", | |
| 2581 | + | "source": { | |
| 2582 | + | "kind": "run", | |
| 2583 | + | "runId": "arn_01m43v2c1p9k8d7e6f5a4b3c2d", | |
| 2584 | + | "repo": { | |
| 2585 | + | "namespace": "syntaqx", | |
| 2586 | + | "name": "hello" | |
| 2587 | + | }, | |
| 2588 | + | "number": 14 | |
| 2589 | + | }, | |
| 2590 | + | "createdBy": "g1t-agent", | |
| 2591 | + | "pinned": false, | |
| 2592 | + | "createdAt": "2026-10-01T18:40:12.000Z", | |
| 2593 | + | "updatedAt": "2026-10-01T18:40:12.000Z", | |
| 2594 | + | "lastUsedAt": null | |
| 2595 | + | }, | |
| 2596 | + | "notes": "Text that looks like a key, a token or a password is refused with `invalid`: memory is read by every agent in the workspace. Saving the same text again in the same scope returns the memory already kept." | |
| 2597 | + | }, | |
| 2598 | + | "recall": { | |
| 2599 | + | "query": { | |
| 2600 | + | "q": "pnpm" | |
| 2601 | + | }, | |
| 2602 | + | "response": { | |
| 2603 | + | "project": [], | |
| 2604 | + | "workspace": [ | |
| 2605 | + | { | |
| 2606 | + | "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z", | |
| 2607 | + | "scope": "workspace", | |
| 2608 | + | "workspace": "syntaqx", | |
| 2609 | + | "repo": null, | |
| 2610 | + | "text": "We use pnpm everywhere, never npm or yarn.", | |
| 2611 | + | "kind": "convention", | |
| 2612 | + | "source": { | |
| 2613 | + | "kind": "person", | |
| 2614 | + | "runId": null, | |
| 2615 | + | "repo": null, | |
| 2616 | + | "number": null | |
| 2617 | + | }, | |
| 2618 | + | "createdBy": "syntaqx", | |
| 2619 | + | "pinned": true, | |
| 2620 | + | "createdAt": "2026-10-01T18:40:12.000Z", | |
| 2621 | + | "updatedAt": "2026-10-01T18:40:12.000Z", | |
| 2622 | + | "lastUsedAt": null | |
| 2623 | + | } | |
| 2624 | + | ] | |
| 2625 | + | }, | |
| 2626 | + | "notes": "Members of the workspace only. Each memory returned is marked used, which keeps it near the front of what agents are given." | |
| 2563 | 2627 | } | |
| 2564 | 2628 | } |
| 1 | + | //! Old workspace slugs, after a rename. An operation that names a | |
| 2 | + | //! repository (`owner/name`) or a workspace by a slug the workspace has | |
| 3 | + | //! since been renamed from is run again under the slug it has now, so | |
| 4 | + | //! scripts and agents written against the old address keep working while | |
| 5 | + | //! it redirects. | |
| 6 | + | ||
| 7 | + | use g1t_contracts::identity::SlugArgs; | |
| 8 | + | use serde_json::Value; | |
| 9 | + | use worker::Result; | |
| 10 | + | ||
| 11 | + | use crate::operations::Services; | |
| 12 | + | ||
| 13 | + | /// The workspace slug an operation's input names: the owner in `repo`, or | |
| 14 | + | /// `workspace`. | |
| 15 | + | pub fn named_slug(input: &Value) -> Option<&str> { | |
| 16 | + | input["repo"] | |
| 17 | + | .as_str() | |
| 18 | + | .and_then(|repo| repo.split_once('/')) | |
| 19 | + | .map(|(owner, _)| owner) | |
| 20 | + | .or_else(|| input["workspace"].as_str()) | |
| 21 | + | .filter(|slug| !slug.is_empty()) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// `input` with `old` replaced by `new` wherever it names the workspace. | |
| 25 | + | pub fn rewrite(input: &Value, old: &str, new: &str) -> Value { | |
| 26 | + | let mut input = input.clone(); | |
| 27 | + | if let Some(repo) = input["repo"].as_str() | |
| 28 | + | && let Some((owner, name)) = repo.split_once('/') | |
| 29 | + | && owner.eq_ignore_ascii_case(old) | |
| 30 | + | { | |
| 31 | + | input["repo"] = Value::String(format!("{new}/{name}")); | |
| 32 | + | } | |
| 33 | + | if input["workspace"] | |
| 34 | + | .as_str() | |
| 35 | + | .is_some_and(|workspace| workspace.eq_ignore_ascii_case(old)) | |
| 36 | + | { | |
| 37 | + | input["workspace"] = Value::String(new.to_owned()); | |
| 38 | + | } | |
| 39 | + | input | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The input under the workspace's current slug, if it names an old one. | |
| 43 | + | pub async fn retarget(services: &Services, input: &Value) -> Result<Option<Value>> { | |
| 44 | + | let Some(old) = named_slug(input) else { | |
| 45 | + | return Ok(None); | |
| 46 | + | }; | |
| 47 | + | let current: Option<String> = g1t_kit::call( | |
| 48 | + | &services.identity, | |
| 49 | + | "resolve_slug", | |
| 50 | + | &SlugArgs { | |
| 51 | + | slug: old.to_owned(), | |
| 52 | + | }, | |
| 53 | + | ) | |
| 54 | + | .await?; | |
| 55 | + | Ok(current.map(|new| rewrite(input, old, &new))) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[cfg(test)] | |
| 59 | + | mod tests { | |
| 60 | + | use super::*; | |
| 61 | + | use serde_json::json; | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn finds_the_slug_an_operation_names() { | |
| 65 | + | assert_eq!(named_slug(&json!({ "repo": "acme/rocket" })), Some("acme")); | |
| 66 | + | assert_eq!(named_slug(&json!({ "workspace": "acme" })), Some("acme")); | |
| 67 | + | assert_eq!(named_slug(&json!({ "id": "x" })), None); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn rewrites_only_the_old_slug() { | |
| 72 | + | let input = json!({ "repo": "Acme/rocket", "workspace": "acme", "title": "acme/x" }); | |
| 73 | + | assert_eq!( | |
| 74 | + | rewrite(&input, "acme", "acme-inc"), | |
| 75 | + | json!({ "repo": "acme-inc/rocket", "workspace": "acme-inc", "title": "acme/x" }) | |
| 76 | + | ); | |
| 77 | + | let other = json!({ "repo": "globex/rocket" }); | |
| 78 | + | assert_eq!(rewrite(&other, "acme", "acme-inc"), other); | |
| 79 | + | } | |
| 80 | + | } |
| 65 | 65 | Op::AnswerMessage, | |
| 66 | 66 | &[], | |
| 67 | 67 | ), | |
| 68 | + | route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]), | |
| 69 | + | route( | |
| 70 | + | "GET", | |
| 71 | + | "/repos/:owner/:name/memory", | |
| 72 | + | Op::Recall, | |
| 73 | + | &[("q", "query"), ("limit", "limit")], | |
| 74 | + | ), | |
| 68 | 75 | route( | |
| 69 | 76 | "GET", | |
| 70 | 77 | "/repos/:owner/:name/events", |
| 90 | 90 | { label: 'g1t agents', slug: 'guides/g1t-agents' }, | |
| 91 | 91 | { label: 'Outcomes and plans', slug: 'guides/outcomes' }, | |
| 92 | 92 | { label: 'Talking to agents', slug: 'guides/talking-to-agents' }, | |
| 93 | + | { label: 'Agents, sessions and memory', slug: 'guides/agents-and-memory' }, | |
| 93 | 94 | { label: 'Bring your own agent', slug: 'guides/bring-your-own-agent' }, | |
| 94 | 95 | ], | |
| 95 | 96 | }, | |
| 105 | 106 | { | |
| 106 | 107 | label: 'Landing changes', | |
| 107 | 108 | items: [ | |
| 109 | + | { label: 'Pull requests and checks', slug: 'guides/pull-requests' }, | |
| 108 | 110 | { label: 'The merge queue', slug: 'guides/merge-queue' }, | |
| 109 | 111 | { label: 'Sessions and why-blame', slug: 'guides/why-blame' }, | |
| 110 | 112 | { label: 'Forks and branches', slug: 'concepts/forks' }, |
| 1 | + | --- | |
| 2 | + | title: Agents, sessions and memory | |
| 3 | + | description: Watch every agent at work, stop or steer a run, read past sessions, and curate what agents remember about a project and across a workspace. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | Every project has an **Agents** section, beside its pull requests: who is | |
| 7 | + | working on it right now, what each agent did before, and what agents have | |
| 8 | + | learned about the project. The workspace has the same across all of its | |
| 9 | + | projects: the **Agent fleet** and **Workspace memory**. | |
| 10 | + | ||
| 11 | + | | You want to | Go to | | |
| 12 | + | | --- | --- | | |
| 13 | + | | See what agents are doing on a project now | **Agents → At work** | | |
| 14 | + | | Stop a run, or tell it something | **Stop** or **Message** on the run | | |
| 15 | + | | See how a pull request was made | **Agents → Sessions**, or the pull request's Agent panel | | |
| 16 | + | | Teach every agent something about this code | **Agents → Memory** | | |
| 17 | + | | Teach every agent something true in every project | **Workspace memory**, under Across projects | | |
| 18 | + | | See every agent across the workspace, and its cost | **Agent fleet**, under Across projects | | |
| 19 | + | ||
| 20 | + | ## Runs | |
| 21 | + | ||
| 22 | + | A **run** is one sandbox g1t starts. Each g1t agent run does one kind of | |
| 23 | + | work on one pull request: | |
| 24 | + | ||
| 25 | + | | Kind | What the agent does | | |
| 26 | + | | --- | --- | | |
| 27 | + | | `implement` | Makes the change an issue asks for, in a new pull request. | | |
| 28 | + | | `revise` | Addresses failed checks or a review on its own pull request. | | |
| 29 | + | | `review` | Reviews a pull request and gives a verdict. | | |
| 30 | + | | `answer` | Answers another agent's question or handoff. | | |
| 31 | + | | `update` | Merges in the branch its pull request will land on. | | |
| 32 | + | | `plan` | Turns an outcome into a plan of issues. | | |
| 33 | + | ||
| 34 | + | Sandboxes that run commands rather than a model show too, as `checks` and | |
| 35 | + | `queue`, so the list is everything g1t is running for the project. | |
| 36 | + | ||
| 37 | + | Each run records: | |
| 38 | + | ||
| 39 | + | - the agent (`g1t-agent`) and, for members, the model it runs on; | |
| 40 | + | - its pull request, or for a plan, the outcome; | |
| 41 | + | - its status: starting, running, done, failed or stopped; | |
| 42 | + | - its **current step**, one line such as `Edited src/auth.ts` or | |
| 43 | + | `Ran npm test`, and the latest 200 steps; | |
| 44 | + | - how long it has run, and for members, what it has cost so far. | |
| 45 | + | ||
| 46 | + | The cost is what the agent harness reports as the run goes. What the | |
| 47 | + | workspace is charged is on its [Usage](/guides/usage-and-billing/) page. | |
| 48 | + | ||
| 49 | + | ### At work | |
| 50 | + | ||
| 51 | + | **Agents → At work** lists the project's runs: the ones under way first, | |
| 52 | + | updating every few seconds while any is running, then the ones that | |
| 53 | + | recently finished. Choose a run's step count for its step-by-step page, | |
| 54 | + | or **Session** for the full record of its pull request. | |
| 55 | + | ||
| 56 | + | The pull request page shows the same for its own agent, near the top: who | |
| 57 | + | is working on it, its stage, what it is doing this minute, for how long, | |
| 58 | + | and what it has cost. The pull request list marks each pull request an | |
| 59 | + | agent is working on with what it is doing, such as **Revising**. An issue | |
| 60 | + | shows which agent picked it up and its current step. | |
| 61 | + | ||
| 62 | + | ### Stop a run | |
| 63 | + | ||
| 64 | + | Members of the workspace can stop a run with **Stop**: | |
| 65 | + | ||
| 66 | + | - its sandbox shuts down at once, and nothing more is pushed; | |
| 67 | + | - what it already pushed stays on the pull request; | |
| 68 | + | - g1t stops seeing that pull request through, and marks it **Needs you**, | |
| 69 | + | so it does not start the same work again on its own. | |
| 70 | + | ||
| 71 | + | To start again, ask for what you want on the pull request: a review, a | |
| 72 | + | catch-up, or changes in a review, which sends the agent back to revise. | |
| 73 | + | ||
| 74 | + | ### Message a run | |
| 75 | + | ||
| 76 | + | **Message** sends the agent on the run's pull request a message, the same | |
| 77 | + | as **Message the agent** on the pull request. When it arrives depends on | |
| 78 | + | the run: | |
| 79 | + | ||
| 80 | + | - **Implement, revise and answer runs** read messages while they work, at | |
| 81 | + | their next step between tool calls. | |
| 82 | + | - **Review, update and plan runs** do not. The message waits on the pull | |
| 83 | + | request and is given to the agent's next run there. g1t says so when you | |
| 84 | + | send it. | |
| 85 | + | ||
| 86 | + | See [talk to agents](/guides/talking-to-agents/) for what an agent does | |
| 87 | + | with a message. | |
| 88 | + | ||
| 89 | + | ## Sessions | |
| 90 | + | ||
| 91 | + | **Agents → Sessions** lists every pull request with a recorded session, | |
| 92 | + | most recently active first, with its first prompt, how many entries and | |
| 93 | + | tool calls it has, the kinds of runs g1t made for it, and their cost. | |
| 94 | + | Filter by kind of run, by outcome (in progress, ready for review, merged | |
| 95 | + | or closed), or by pull request number. | |
| 96 | + | ||
| 97 | + | A session's page shows its runs, each with its steps, then the session | |
| 98 | + | itself: prompts, what the agent said, the tools it ran and, with **Show | |
| 99 | + | tool results**, what they returned. Sessions from your own agents, recorded | |
| 100 | + | with `record_session`, are listed the same way. See | |
| 101 | + | [sessions and why-blame](/guides/why-blame/) for what a session records. | |
| 102 | + | ||
| 103 | + | A session is as visible as its project. The model and the cost of a run | |
| 104 | + | are shown only to members of the workspace. | |
| 105 | + | ||
| 106 | + | ## Memory | |
| 107 | + | ||
| 108 | + | Memory is what agents and people have learned that the next agent should | |
| 109 | + | know. It has two levels. | |
| 110 | + | ||
| 111 | + | **Project memory** is about one project's code: | |
| 112 | + | ||
| 113 | + | - how to build and test it: "Run `npm run db:reset` before the integration | |
| 114 | + | tests"; | |
| 115 | + | - its conventions: "Components live in `src/components/ui`; import from | |
| 116 | + | there, not from radix-ui"; | |
| 117 | + | - decisions and why: "We keep the v1 webhook payload; two customers still | |
| 118 | + | parse it"; | |
| 119 | + | - its traps: "The date tests fail unless `TZ=UTC`". | |
| 120 | + | ||
| 121 | + | **Workspace memory** is true across all of the workspace's projects: | |
| 122 | + | ||
| 123 | + | - "We use pnpm everywhere, never npm or yarn"; | |
| 124 | + | - "Staging lives at staging.example.com and deploys from main"; | |
| 125 | + | - "Every service logs JSON to stdout"; | |
| 126 | + | - "Ask Ana before changing anything under billing". | |
| 127 | + | ||
| 128 | + | Put something in workspace memory only when it holds in every project. When | |
| 129 | + | it is true of one codebase, it belongs to that project. | |
| 130 | + | ||
| 131 | + | ### How agents use it | |
| 132 | + | ||
| 133 | + | Every g1t agent run is given memory when it starts: the workspace's and | |
| 134 | + | the project's, each labelled, pinned memories first, then the ones used | |
| 135 | + | most recently, up to about 6,000 characters. Agents are told to treat it as | |
| 136 | + | notes from colleagues: usually right, sometimes out of date, and where it | |
| 137 | + | disagrees with the code, the code wins. | |
| 138 | + | ||
| 139 | + | Agents add to it as they work with the `remember` tool, choosing the scope | |
| 140 | + | themselves: `project` for this codebase, `workspace` for what holds across | |
| 141 | + | projects. Each memory records where it came from: the person who wrote it, | |
| 142 | + | or the agent's run and the pull request it was working on, linked from the | |
| 143 | + | memory. | |
| 144 | + | ||
| 145 | + | Your own agents can use memory too, through the [MCP tools](/reference/mcp/#memory) | |
| 146 | + | `remember` and `recall` or the API: | |
| 147 | + | ||
| 148 | + | ```sh | |
| 149 | + | curl -X POST https://api.g1t.sh/repos/acme/web/memory \ | |
| 150 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 151 | + | -H "Content-Type: application/json" \ | |
| 152 | + | -d '{"text": "The date tests fail unless TZ=UTC.", "kind": "gotcha"}' | |
| 153 | + | ||
| 154 | + | curl "https://api.g1t.sh/repos/acme/web/memory?q=tests" \ | |
| 155 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 156 | + | ``` | |
| 157 | + | ||
| 158 | + | Each memory `recall` returns counts as used, which keeps it near the front | |
| 159 | + | of what agents are given. | |
| 160 | + | ||
| 161 | + | ### Curate it | |
| 162 | + | ||
| 163 | + | Memory is only as good as it is true. On **Agents → Memory** and on | |
| 164 | + | **Workspace memory**, members can: | |
| 165 | + | ||
| 166 | + | - **add** a memory, with its kind: fact, convention, decision or gotcha; | |
| 167 | + | - **pin** one, so every agent gets it first, whatever the budget; | |
| 168 | + | - **edit** one that has drifted, or change its kind; | |
| 169 | + | - **forget** one that no longer holds. | |
| 170 | + | ||
| 171 | + | Each shows who added it, when, and when it was last given to an agent. A | |
| 172 | + | memory that has not been given to an agent for a long time is a good one to | |
| 173 | + | check. Saving the same text twice keeps one memory. A project or a | |
| 174 | + | workspace keeps up to 500. | |
| 175 | + | ||
| 176 | + | The project's Memory page also lists the workspace's memory, read-only, | |
| 177 | + | since agents there get both; manage it from **Workspace memory**. | |
| 178 | + | ||
| 179 | + | ### Who can see it | |
| 180 | + | ||
| 181 | + | Memory is for members of the workspace and their agents. It is never shown | |
| 182 | + | on a public project's pages, since it can hold what the workspace keeps to | |
| 183 | + | itself. | |
| 184 | + | ||
| 185 | + | ### Never a secret | |
| 186 | + | ||
| 187 | + | Every agent in the workspace reads memory, so it never holds a secret. g1t | |
| 188 | + | refuses text that looks like one: a key or token with a known prefix (such | |
| 189 | + | as `sk-`, `ghp_`, `AKIA` or `g1t_`), a private key, a URL with a password | |
| 190 | + | in it, `password=` or `token:` followed by a value, or a long random string. | |
| 191 | + | Say where the secret lives instead: "The deploy key is the `DEPLOY_KEY` | |
| 192 | + | secret". Agents read secrets from [secrets and variables](/guides/secrets-and-variables/), | |
| 193 | + | never from memory. |
| 21 | 21 | `pr-<number>` in place of the branch. A name too long for an address, or | |
| 22 | 22 | one another app already has, is shortened or given a short suffix. | |
| 23 | 23 | ||
| 24 | + | When a workspace is [renamed](/guides/workspaces/#rename-a-workspace), its | |
| 25 | + | apps move to addresses with the new name, and the old addresses redirect to | |
| 26 | + | them for 90 days. | |
| 27 | + | ||
| 24 | 28 | An app runs only while it answers a request. One nobody visits runs | |
| 25 | 29 | nothing and costs nothing, and the next visit wakes it in milliseconds. | |
| 26 | 30 | ||
| 133 | 137 | branch, builds and replaces production. The **Deployments** page shows the | |
| 134 | 138 | live address, the commit it runs and when it went up. | |
| 135 | 139 | ||
| 140 | + | ## Custom domains | |
| 141 | + | ||
| 142 | + | Production can be served at a domain of your own, such as `example.com` or | |
| 143 | + | `www.example.com`, as well as at its address on g1t.page. A domain belongs | |
| 144 | + | to the project, not to a build: every redeploy is served on it with nothing | |
| 145 | + | to change. | |
| 146 | + | ||
| 147 | + | ### Add a domain | |
| 148 | + | ||
| 149 | + | 1. Open the project's **Settings → Domains**, | |
| 150 | + | `g1t.sh/<workspace>/<repo>/settings/domains`. | |
| 151 | + | 2. Enter the domain and choose **Add domain**. Leave **Also add the www | |
| 152 | + | (or apex) twin** checked to add both `example.com` and | |
| 153 | + | `www.example.com` at once: the one you typed serves the app, and the | |
| 154 | + | other redirects to it with a `308`, path and query kept. | |
| 155 | + | 3. Add the DNS records the page lists, at whoever manages the domain's | |
| 156 | + | DNS. Each name and value has a copy button. | |
| 157 | + | 4. Wait. The page follows the domain from **Waiting for DNS** to | |
| 158 | + | **Issuing certificate** to **Active** by itself; **Check now** asks | |
| 159 | + | again at once. | |
| 160 | + | ||
| 161 | + | Custom domains need the workspace's Deployments plan. Only members of the | |
| 162 | + | workspace can add or remove them. | |
| 163 | + | ||
| 164 | + | ### A subdomain, such as www | |
| 165 | + | ||
| 166 | + | Add one record: | |
| 167 | + | ||
| 168 | + | | Type | Name | Target | | |
| 169 | + | | --- | --- | --- | | |
| 170 | + | | `CNAME` | `www` | `domains.g1t.page` | | |
| 171 | + | ||
| 172 | + | The same goes for any subdomain, such as `app.example.com` (name `app`). | |
| 173 | + | ||
| 174 | + | ### The apex, such as example.com | |
| 175 | + | ||
| 176 | + | DNS does not allow a plain `CNAME` at the apex of a domain, so use your | |
| 177 | + | provider's flattened form of one, pointed at `domains.g1t.page`: | |
| 178 | + | ||
| 179 | + | | DNS provider | Record | | |
| 180 | + | | --- | --- | | |
| 181 | + | | Cloudflare DNS | `CNAME` at `@` (Cloudflare flattens it) | | |
| 182 | + | | Amazon Route 53 | Not supported by alias records to other zones; use the www pairing below | | |
| 183 | + | | DNSimple, NS1, Namecheap, Porkbun, Gandi | `ALIAS` at `@` | | |
| 184 | + | | DNS Made Easy, Constellix | `ANAME` at `@` | | |
| 185 | + | ||
| 186 | + | If your provider has no flattened `CNAME`, `ALIAS` or `ANAME`, add | |
| 187 | + | `www.example.com` on g1t instead, and set up your provider's forwarding (or | |
| 188 | + | any redirect) from `example.com` to `www.example.com`. | |
| 189 | + | ||
| 190 | + | ### Verification and certificates | |
| 191 | + | ||
| 192 | + | Pointing the domain at `domains.g1t.page` is what proves it is yours: once | |
| 193 | + | the record is seen, the certificate authority checks the domain over HTTP | |
| 194 | + | and issues a certificate, renewed by itself before it expires. Visitors are | |
| 195 | + | always served over HTTPS, TLS 1.2 or newer. | |
| 196 | + | ||
| 197 | + | The page may also list a `TXT` record named `_cf-custom-hostname.<domain>`. | |
| 198 | + | It proves ownership before traffic moves, which is useful when the domain | |
| 199 | + | is serving a site elsewhere today: add the `TXT` first, wait for | |
| 200 | + | **Issuing certificate** or **Active**, then change the `CNAME`. Any other | |
| 201 | + | `TXT` records listed are for the certificate and are needed as shown. | |
| 202 | + | ||
| 203 | + | DNS changes can take from a few minutes to an hour to be seen. A domain | |
| 204 | + | that stays at **Waiting for DNS** usually has a record with a typo, a | |
| 205 | + | leftover `A` or `AAAA` record beside the new one, or (on Cloudflare DNS) a | |
| 206 | + | `CAA` record that does not allow the certificate's authority. | |
| 207 | + | ||
| 208 | + | ### Remove a domain | |
| 209 | + | ||
| 210 | + | Choose **Remove** beside it. It stops serving the project at once, and any | |
| 211 | + | domain redirecting to it goes too. Your DNS records are left as they are. | |
| 212 | + | ||
| 136 | 213 | ## When apps come down | |
| 137 | 214 | ||
| 138 | 215 | Nothing keeps running unasked. An app comes down, and stops costing | |
| 195 | 272 | | 10 apps | The most apps up at once: production and previews together, across the workspace's projects. | | |
| 196 | 273 | | 1 million requests | To all of the workspace's apps. | | |
| 197 | 274 | | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. | | |
| 275 | + | | 3 custom domains | Across the workspace's projects, with their certificates. A www/apex pair is two. | | |
| 198 | 276 | ||
| 199 | 277 | **Usage past that,** and **every build**, come out of the workspace's | |
| 200 | 278 | [credit](/guides/usage-and-billing/#add-credit) at Cloudflare's price plus | |
| 206 | 284 | | Each app past 10 | $0.024 a month | | |
| 207 | 285 | | Each million requests past 1 million | $0.36 | | |
| 208 | 286 | | Each million CPU milliseconds past 3 million | $0.024 | | |
| 287 | + | | Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month | | |
| 209 | 288 | ||
| 210 | 289 | Builds are charged when they finish. Usage past the allowance is charged | |
| 211 | 290 | once, on the first sweep after the month ends, as one line: *Deployments in | |
| 251 | 330 | g1t's [Workers for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/) | |
| 252 | 331 | namespace, under the name in its address. | |
| 253 | 332 | 5. A request to `*.g1t.page` reaches g1t's dispatcher, which runs the app | |
| 254 | − | by the name in the hostname. Nothing else is looked up. | |
| 333 | + | by the name in the hostname. Nothing else is looked up. A request to a | |
| 334 | + | custom domain reaches the same dispatcher through Cloudflare for SaaS; | |
| 335 | + | it looks the hostname up once, in a key-value store kept at the edge. | |
| 255 | 336 | ||
| 256 | 337 | Your code never holds a Cloudflare credential, and apps are served from | |
| 257 | 338 | `g1t.page`, not `g1t.sh`, so they share no cookies or origin with the site | |
| 266 | 347 | | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. | | |
| 267 | 348 | | "`d1_databases` is not provisioned on g1t.page yet" | The app deployed without that binding. See [Workers projects](#workers-projects). | | |
| 268 | 349 | | A preview page says "This preview is not up" | It came down (see [When apps come down](#when-apps-come-down)). Push, or choose **Redeploy**. | | |
| 350 | + | | "Custom domains are being switched on" | Custom domains are not on for g1t.page yet. Domains you add are kept, and set up by themselves once they are. | | |
| 351 | + | | A domain stays at **Waiting for DNS** | Check the record against the one listed, remove other `A`/`AAAA` records for the same name, then choose **Check now**. | | |
| 352 | + | | A domain says "This domain is not set up" | It points at g1t, but no project has added it. Add it under **Settings → Domains**. | | |
| 269 | 353 | | "The build did not finish in 45 minutes" | Builds are stopped after 45 minutes. Make the build faster, or build less for previews with **Build command**. | | |
| 270 | 354 | ||
| 271 | 355 | ## Running your own g1t | |
| 280 | 364 | `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`. | |
| 281 | 365 | 4. Deploy `services/deployments`, `services/pages`, and the runner. | |
| 282 | 366 | ||
| 367 | + | Custom domains also need Cloudflare for SaaS on the apps' zone. Turn it on | |
| 368 | + | under the zone's **SSL/TLS → Custom Hostnames**, give the token **SSL and | |
| 369 | + | Certificates: Edit** on the zone, and run `scripts/setup-custom-domains.sh`: | |
| 370 | + | it creates the `g1t-domains` KV namespace, the fallback origin's DNS record | |
| 371 | + | (`domains`, `AAAA`, `100::`, proxied) and sets it as the fallback origin. | |
| 372 | + | The dispatcher's `*/*` route on the zone, in `services/pages/wrangler.jsonc`, | |
| 373 | + | is what brings custom domains' traffic to it. | |
| 374 | + | ||
| 283 | 375 | Without a card processor configured, every feature is on and nothing is | |
| 284 | 376 | charged. |
| 92 | 92 | pull requests to be up to date before merging** in its settings; catching | |
| 93 | 93 | up is then a step of its own, before "ready". | |
| 94 | 94 | ||
| 95 | + | g1t also works out ahead of time whether each pull request still merges | |
| 96 | + | cleanly, every time it or `main` moves | |
| 97 | + | ([how](/guides/pull-requests/#conflicts)). When one of an agent's pull | |
| 98 | + | requests is found to conflict, g1t does not wait for a merge to trip over | |
| 99 | + | it: the agent is sent to merge `main` in and resolve the conflicts, told | |
| 100 | + | which files conflict, and the checks run again on the result. | |
| 101 | + | ||
| 95 | 102 | The pull request's page shows which step it is at. If g1t cannot finish, | |
| 96 | 103 | because the checks still fail after two revisions, a review could not be | |
| 97 | 104 | written, or a conflict could not be resolved while bringing it up to date, |
| 17 | 17 | git clone https://g1t.sh/syntaqx/g1t.git | |
| 18 | 18 | ``` | |
| 19 | 19 | ||
| 20 | + | If the workspace is [renamed](/guides/workspaces/#rename-a-workspace), the | |
| 21 | + | old remote redirects to the new one for 90 days. Git follows the redirect | |
| 22 | + | and warns about it; point the remote at the new address: | |
| 23 | + | ||
| 24 | + | ```sh | |
| 25 | + | git remote set-url origin https://g1t.sh/<new-workspace>/<repo>.git | |
| 26 | + | ``` | |
| 27 | + | ||
| 20 | 28 | ## Authentication | |
| 21 | 29 | ||
| 22 | 30 | Pushing, and reading private repositories, needs credentials. Use your |
| 106 | 106 | - If `main` moved outside the queue, every entry is tested again on the new | |
| 107 | 107 | `main`. | |
| 108 | 108 | ||
| 109 | + | A pull request that is already known to conflict with `main` is not added | |
| 110 | + | to the queue: [its merge box](/guides/pull-requests/#conflicts) says which | |
| 111 | + | files conflict and how to resolve them first. | |
| 112 | + | ||
| 109 | 113 | ## When an entry fails | |
| 110 | 114 | ||
| 111 | 115 | An entry fails when its checks or its `merge_group` workflows fail in the | |
| 116 | 120 | 1. Its pull request gets a failed check run. Each command is named with the | |
| 117 | 121 | state it ran in, such as `cargo test (merge queue, on the default branch | |
| 118 | 122 | with #41 merged in first)`, and the run says why it failed. A conflict | |
| 119 | − | names the pull request ahead it collided with. | |
| 120 | − | 2. Its conversation records that it was taken out of the queue, and why. | |
| 123 | + | names the pull request ahead it collided with, and the files. | |
| 124 | + | 2. Its conversation records that it was taken out of the queue, and why: a | |
| 125 | + | conflict links the pull request it collided with and each conflicting | |
| 126 | + | file, which opens in the pull request's changes. | |
| 121 | 127 | 3. The entries that were tested on top of it are tested again without it. | |
| 122 | 128 | ||
| 123 | 129 | A g1t agent's pull request is then sent back to revise, like any failed |
| 1 | + | --- | |
| 2 | + | title: Pull requests and checks | |
| 3 | + | description: What the merge box shows before a pull request can merge, every check and what a failed one printed, and conflicts found before anyone tries to merge. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | At the foot of an open pull request's conversation, the **merge box** says | |
| 7 | + | what stands between it and its target branch: its checks, its reviews, | |
| 8 | + | whether it merges cleanly, and the button that merges it. Everything in it | |
| 9 | + | updates by itself while something is still running. | |
| 10 | + | ||
| 11 | + | ## Checks | |
| 12 | + | ||
| 13 | + | A pull request has two kinds of checks, and the merge box lists both in | |
| 14 | + | one place: | |
| 15 | + | ||
| 16 | + | - **Acceptance checks**: the commands written on the issue it is for, run | |
| 17 | + | against its head in a clean sandbox that holds nothing but that commit. | |
| 18 | + | No agent runs there, so a pass says something about the code. See | |
| 19 | + | [acceptance checks](/concepts/overview/#acceptance-checks). | |
| 20 | + | - **Workflows**: the repository's [GitHub Actions workflows](/guides/actions/) | |
| 21 | + | run on its head, listed job by job. | |
| 22 | + | ||
| 23 | + | The first line sums them up: **All checks have passed**, **2 of 5 checks | |
| 24 | + | failed**, or how many are still running. Below it, each check has a row with | |
| 25 | + | its state, its command or job name, and how long it took. | |
| 26 | + | ||
| 27 | + | ### What a failed check printed | |
| 28 | + | ||
| 29 | + | A failed acceptance check opens by itself and shows: | |
| 30 | + | ||
| 31 | + | - the exit code, or that it was stopped for taking too long (ten minutes); | |
| 32 | + | - what it printed, standard output and error together, as a log with line | |
| 33 | + | numbers and its colours, and a button to copy it; | |
| 34 | + | - a note when the output was long and only its end was kept, which is where | |
| 35 | + | failures are. | |
| 36 | + | ||
| 37 | + | A workflow job's **Details** opens its log on the run's page. | |
| 38 | + | ||
| 39 | + | Above the acceptance checks is the commit they ran on, linked. If the pull | |
| 40 | + | request has been pushed to since, the box says so: those results are for | |
| 41 | + | an older commit, and the latest push has not been checked yet. | |
| 42 | + | ||
| 43 | + | When the checks could not be run at all, for example because the sandbox | |
| 44 | + | stopped, the box says why instead of listing results. | |
| 45 | + | ||
| 46 | + | ### Running them again | |
| 47 | + | ||
| 48 | + | **Re-run checks** runs the acceptance checks again on the current head. | |
| 49 | + | Whoever opened the pull request and members of the workspace can. Members | |
| 50 | + | can also **Re-run failed jobs** of a workflow that failed. | |
| 51 | + | ||
| 52 | + | **Earlier runs of the acceptance checks** lists the runs before the latest, | |
| 53 | + | with the commit each ran on and how it went. | |
| 54 | + | ||
| 55 | + | The acceptance checks also run again by themselves on every push. | |
| 56 | + | ||
| 57 | + | ## Conflicts | |
| 58 | + | ||
| 59 | + | g1t works out whether a pull request merges cleanly into its target before | |
| 60 | + | anyone tries to merge it, and again whenever either side moves: a push to | |
| 61 | + | the pull request, or anything landing on the target branch. | |
| 62 | + | ||
| 63 | + | 1. **Without a sandbox.** g1t compares the files the pull request changed | |
| 64 | + | since it and the target last agreed with the files the target changed | |
| 65 | + | since then. If they share none, the merge cannot conflict, and that is | |
| 66 | + | the answer. | |
| 67 | + | 2. **With a short probe.** If they share files, a sandbox merges the two | |
| 68 | + | commits without an agent and pushes nothing, and reports the files that | |
| 69 | + | conflict. Meanwhile the box says **Checking whether this merges cleanly**, | |
| 70 | + | and the merge button waits. Probes are metered as sandbox time, like | |
| 71 | + | checks; each pair of commits is probed once, and a repository runs at most | |
| 72 | + | three at a time, the rest following in turn. | |
| 73 | + | ||
| 74 | + | A pull request that conflicts shows **This branch has conflicts that must | |
| 75 | + | be resolved**, the conflicting files, each linked to its diff, and three | |
| 76 | + | ways to resolve them: | |
| 77 | + | ||
| 78 | + | - **Resolve with g1t agent.** An agent merges the target branch in, | |
| 79 | + | resolves the conflicts keeping what both sides meant, and pushes the | |
| 80 | + | result. It is told which files conflict. Available to whoever can push to | |
| 81 | + | the pull request: for a pull request's fork, whoever opened it; for a | |
| 82 | + | branch, any member. | |
| 83 | + | - **Resolve in the browser.** Coming soon. | |
| 84 | + | - **On the command line.** The box lists the commands, each with a copy | |
| 85 | + | button. For a pull request from a branch: | |
| 86 | + | ||
| 87 | + | ```sh | |
| 88 | + | git fetch origin | |
| 89 | + | git checkout my-branch | |
| 90 | + | git merge origin/main | |
| 91 | + | # fix each conflicting file, then | |
| 92 | + | git add -A && git commit --no-edit | |
| 93 | + | git push origin my-branch | |
| 94 | + | ``` | |
| 95 | + | ||
| 96 | + | For a pull request in its own fork, clone the fork and pull `main` into | |
| 97 | + | it instead: | |
| 98 | + | ||
| 99 | + | ```sh | |
| 100 | + | git clone https://g1t.sh/pulls/<id>.git && cd <id> | |
| 101 | + | git pull --no-rebase https://g1t.sh/<owner>/<repo>.git main | |
| 102 | + | ``` | |
| 103 | + | ||
| 104 | + | While it conflicts, it cannot be merged or added to the | |
| 105 | + | [merge queue](/guides/merge-queue/), and the merge button says so. Once the | |
| 106 | + | fix is pushed, g1t works it out again. | |
| 107 | + | ||
| 108 | + | A pull request that only has fallen behind its target, without conflicts, | |
| 109 | + | still merges: merging brings it up to date first, unless the repository | |
| 110 | + | requires pull requests to be up to date. | |
| 111 | + | ||
| 112 | + | A [g1t agent's](/guides/g1t-agents/) pull request that is found to conflict | |
| 113 | + | is sent back to resolve it by itself, before it is ready. | |
| 114 | + | ||
| 115 | + | ## From the API | |
| 116 | + | ||
| 117 | + | `GET /repos/{owner}/{name}/pulls/{number}` returns, besides the pull request: | |
| 118 | + | ||
| 119 | + | | Field | What it is | | |
| 120 | + | | --- | --- | | |
| 121 | + | | `checks` | The latest run of the acceptance checks: `status`, `headCommit`, `error`, and `results`, each with `command`, `passed`, `exitCode`, `output` and `durationMs`. | | |
| 122 | + | | `earlierChecks` | The runs before it, newest first, without their output. | | |
| 123 | + | | `statuses` | What each workflow run said about its head. | | |
| 124 | + | | `mergeable` | `clean`, `conflicting`, `checking` or `unknown`. | | |
| 125 | + | | `conflicts` | When conflicting, the files that conflict. | | |
| 126 | + | | `behind` | Whether its target has moved on without it. | | |
| 127 | + | ||
| 128 | + | An agent sees the same through the `get_pull_request` tool. |
| 1 | 1 | --- | |
| 2 | 2 | title: Workspaces | |
| 3 | − | description: Workspaces, their names and icons, members and roles, and access tokens that belong to a workspace. | |
| 3 | + | description: Workspaces, their names and icons, renaming one, members and roles, and access tokens that belong to a workspace. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A workspace owns repositories and is the first part of their address: | |
| 15 | 15 | ||
| 16 | 16 | 1. Open [g1t.sh/workspaces/new](https://g1t.sh/workspaces/new). | |
| 17 | 17 | 2. Choose its name in URLs: lowercase letters, digits and single hyphens. | |
| 18 | − | It cannot be changed later, because repository addresses and clones | |
| 19 | − | depend on it. | |
| 18 | + | An owner can [change it later](#rename-a-workspace), and old addresses | |
| 19 | + | redirect for 90 days. | |
| 20 | 20 | 3. Optionally give it a display name. | |
| 21 | 21 | ||
| 22 | 22 | From the API, `POST /workspaces` with `slug` and `name`, or the | |
| 44 | 44 | | | Example | Where it appears | Changes | | |
| 45 | 45 | | --- | --- | --- | --- | | |
| 46 | 46 | | **Display name** | `Flagon Industries` | The sidebar, the top of its page, mission control and link previews | Any time, up to 80 characters; spaces and capitals are fine | | |
| 47 | − | | **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>` and clone URLs | Never, so links and clones keep working | | |
| 47 | + | | **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>`, clone URLs, API paths and `g1t.page` app addresses | By an owner, once a day at most; the old one redirects for 90 days. See [rename a workspace](#rename-a-workspace) | | |
| 48 | 48 | ||
| 49 | 49 | Without a display name, the slug is shown. Where an address is shown, the | |
| 50 | 50 | slug is in monospace beside the name. Owners change the display name and | |
| 51 | 51 | description on **Settings → General**; from the API, `update_workspace`. | |
| 52 | 52 | ||
| 53 | − | A workspace also has an icon, as an organization does on GitHub. Without | |
| 53 | + | A workspace also has an icon. Without | |
| 54 | 54 | one, g1t draws its first letter in a colour of its own. To upload one, an | |
| 55 | 55 | owner opens **Settings → General** and picks an image: | |
| 56 | 56 | ||
| 67 | 67 | You can upload a picture of yourself the same way, under | |
| 68 | 68 | [Settings → Picture](https://g1t.sh/settings#picture). | |
| 69 | 69 | ||
| 70 | + | ## Rename a workspace | |
| 71 | + | ||
| 72 | + | Renaming changes the slug, the first part of every address under the | |
| 73 | + | workspace. The display name is separate; change it on its own under | |
| 74 | + | **Settings → General → Workspace details**. Only owners can rename a | |
| 75 | + | workspace. | |
| 76 | + | ||
| 77 | + | 1. Open the workspace's **Settings → General** and go to **Address**. | |
| 78 | + | 2. Type the new slug. The field shows the new address, `g1t.sh/<new>`, and | |
| 79 | + | whether the name is available. | |
| 80 | + | 3. Choose **Change address**, read what changes, type the new slug to | |
| 81 | + | confirm, and choose **Change address** again. | |
| 82 | + | ||
| 83 | + | You land on the workspace's settings at its new address. Repositories, | |
| 84 | + | issues and the rest move with it within a few seconds. | |
| 85 | + | ||
| 86 | + | ### What changes | |
| 87 | + | ||
| 88 | + | | | Before | After | | |
| 89 | + | | --- | --- | --- | | |
| 90 | + | | Pages | `g1t.sh/old/<repo>` | `g1t.sh/new/<repo>` | | |
| 91 | + | | Git remotes | `https://g1t.sh/old/<repo>.git` | `https://g1t.sh/new/<repo>.git` | | |
| 92 | + | | API paths | `https://api.g1t.sh/repos/old/<repo>` | `https://api.g1t.sh/repos/new/<repo>` | | |
| 93 | + | | MCP tool arguments | `"owner": "old"` | `"owner": "new"` | | |
| 94 | + | | Production apps | `https://<project>-old.g1t.page` | `https://<project>-new.g1t.page` | | |
| 95 | + | | Previews | `https://<project>-git-<branch>-old.g1t.page` | `https://<project>-git-<branch>-new.g1t.page` | | |
| 96 | + | ||
| 97 | + | These stay the same: the display name, description and icon, members and | |
| 98 | + | roles, access tokens, secrets and variables, integrations, webhooks, | |
| 99 | + | issues and pull requests, and billing, plans and credit. | |
| 100 | + | ||
| 101 | + | ### What redirects, and for how long | |
| 102 | + | ||
| 103 | + | For 90 days after a rename, the old name keeps working: | |
| 104 | + | ||
| 105 | + | | | Behaviour | | |
| 106 | + | | --- | --- | | |
| 107 | + | | Web pages | Answer with a permanent redirect (301) to the same page under the new name, query string included. | | |
| 108 | + | | `git clone`, `fetch`, `pull` and `push` | Redirected to the new remote. Git follows it, but prints a warning each time until you update the remote. | | |
| 109 | + | | API | Calls to the old paths redirect to the new ones. | | |
| 110 | + | | `g1t.page` apps | Production and preview addresses under the old name redirect to the new ones. | | |
| 111 | + | ||
| 112 | + | Update your remotes now rather than relying on the redirect: | |
| 113 | + | ||
| 114 | + | ```sh | |
| 115 | + | git remote set-url origin https://g1t.sh/<new>/<repo>.git | |
| 116 | + | ``` | |
| 117 | + | ||
| 118 | + | Update anything else that has the old name written into it, too: links in | |
| 119 | + | READMEs and docs, CI configuration, API clients and MCP clients. | |
| 120 | + | ||
| 121 | + | ### Limits | |
| 122 | + | ||
| 123 | + | - A workspace can be renamed once every 24 hours. | |
| 124 | + | - For 90 days the old name is held for the workspace. Nobody else can take | |
| 125 | + | it, and you can rename back to it. | |
| 126 | + | - After 90 days the redirects stop, and anyone can create a workspace or | |
| 127 | + | register a username with the old name. Links and remotes that still use | |
| 128 | + | it then reach whatever has the name, or nothing. | |
| 129 | + | - The new name follows the same rules as a new workspace: lowercase letters, | |
| 130 | + | digits and single hyphens, up to 39 characters, not a reserved word, and | |
| 131 | + | not another workspace's slug or someone else's username. | |
| 132 | + | ||
| 70 | 133 | ## Members and roles | |
| 71 | 134 | ||
| 72 | 135 | | Role | Can | | |
| 88 | 151 | ||
| 89 | 152 | | Settings | Who | | | |
| 90 | 153 | | --- | --- | --- | | |
| 91 | − | | **General** | Owners | The icon, the display name and a one-line description. The slug is shown, and cannot be changed. | | |
| 154 | + | | **General** | Owners | The icon, the display name, a one-line description and the address (the slug). | | |
| 92 | 155 | | **Members** | Members | Who belongs, and their roles. Owners add and remove people. | | |
| 93 | 156 | | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. | | |
| 94 | 157 | | **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. | | |
| 105 | 168 | ## Workspace access tokens | |
| 106 | 169 | ||
| 107 | 170 | A workspace has access tokens of its own, for CI, integrations and agents | |
| 108 | − | that work for a team. They replace the shared "service account" other | |
| 109 | − | forges need: there is no extra account to create, pay for or lose the | |
| 110 | − | password to. | |
| 171 | + | that work for a team. There is no shared service account to create, pay | |
| 172 | + | for or lose the password to. | |
| 111 | 173 | ||
| 112 | 174 | | | Personal token | Workspace token | | |
| 113 | 175 | | --- | --- | --- | | |
| 125 | 187 | ||
| 126 | 188 | Every member can see a workspace's tokens: the name, who created each and | |
| 127 | 189 | when it was last used. Only owners can create or delete them. | |
| 190 | + | ||
| 191 | + | ## Profiles | |
| 192 | + | ||
| 193 | + | Every person has a profile at `g1t.sh/u/<username>`, apart from the | |
| 194 | + | workspaces at `g1t.sh/<workspace>`. Author names on issues and pull | |
| 195 | + | requests link to it. | |
| 196 | + | ||
| 197 | + | **What it shows.** Your picture, name, username, pronouns, bio, location, | |
| 198 | + | website and when you joined; then your work in three tabs: | |
| 199 | + | ||
| 200 | + | - **Overview:** pull requests merged, open pull requests and issues | |
| 201 | + | opened, and your most recent activity. | |
| 202 | + | - **Pull requests** and **Issues:** everything you opened, newest first, | |
| 203 | + | with filters beside the list for state (open, closed, merged), type, | |
| 204 | + | repository and sort order. Add `?tab=pulls&state=merged` and the like to | |
| 205 | + | link to a filtered list. | |
| 206 | + | ||
| 207 | + | **Edit it** under **Profile** in [Settings](https://g1t.sh/settings). Every | |
| 208 | + | field is optional. The bio takes up to 160 characters and is also what a | |
| 209 | + | link to your profile says. The website must be an `https://` address; | |
| 210 | + | `example.com` is saved as `https://example.com`. Your email address is | |
| 211 | + | never shown. | |
| 212 | + | ||
| 213 | + | **Who sees what.** A profile is public, but the work and workspaces on it | |
| 214 | + | are filtered for whoever is looking: | |
| 215 | + | ||
| 216 | + | | On the profile | Shown to a visitor when | | |
| 217 | + | | --- | --- | | |
| 218 | + | | An issue or pull request, and its title | They can read its repository: it is public, or they are a member of its workspace | | |
| 219 | + | | The counts | Only what they could see is counted | | |
| 220 | + | | A workspace | They are a member of it too, or you made a public project in it, whose page shows that already | | |
| 221 | + | ||
| 222 | + | Someone signed out sees your public work and the workspaces where you made | |
| 223 | + | a public project; nothing else. The link preview for a profile uses only | |
| 224 | + | public work. |
| 83 | 83 | ||
| 84 | 84 | See [sessions and why-blame](/guides/why-blame/). | |
| 85 | 85 | ||
| 86 | + | ## Memory | |
| 87 | + | ||
| 88 | + | | Tool | Required | What it does | Route | | |
| 89 | + | | --- | --- | --- | --- | | |
| 90 | + | | `remember` | `repo`, `text` | Save one fact, convention, decision or gotcha for the next agent. `scope` is `project` (this codebase, the default) or `workspace` (true across its projects); `kind` is `fact`, `convention`, `decision` or `gotcha`. Text that looks like a secret is refused. Members and g1t's agents only. | [`POST /repos/{owner}/{name}/memory`](/reference/api/memory/remember/) | | |
| 91 | + | | `recall` | `repo` | What the project and its workspace remember, pinned first. `query` matches every word; `limit` caps each level. | [`GET /repos/{owner}/{name}/memory`](/reference/api/memory/recall/) | | |
| 92 | + | ||
| 93 | + | See [agents, sessions and memory](/guides/agents-and-memory/). | |
| 94 | + | ||
| 86 | 95 | ## Plans | |
| 87 | 96 | ||
| 88 | 97 | | Tool | Required | What it does | Route | | |
| 169 | 178 | tools: `get_repo`, `list_issues`, `get_issue`, `list_labels`, | |
| 170 | 179 | `create_issue`, `add_comment`, `list_pull_requests`, `get_pull_request`, | |
| 171 | 180 | `get_pull_request_changes`, `read_session`, `get_merge_queue`, | |
| 172 | − | `list_events`, `take_messages`, `message_agent`, `answer_message` and `get_context`. | |
| 181 | + | `list_events`, `take_messages`, `message_agent`, `answer_message`, `remember`, | |
| 182 | + | `recall` and `get_context`. | |
| 173 | 183 | `tools/list` shows such a token only the tools it may use. |
| 99 | 99 | ] | |
| 100 | 100 | }, | |
| 101 | 101 | { | |
| 102 | + | "name": "Memory", | |
| 103 | + | "description": "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.", | |
| 104 | + | "x-tools": [ | |
| 105 | + | "remember", | |
| 106 | + | "recall" | |
| 107 | + | ] | |
| 108 | + | }, | |
| 109 | + | { | |
| 102 | 110 | "name": "Actions", | |
| 103 | 111 | "description": "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.", | |
| 104 | 112 | "x-tools": [ | |
| 1721 | 1729 | } | |
| 1722 | 1730 | } | |
| 1723 | 1731 | }, | |
| 1732 | + | "/repos/{owner}/{name}/memory": { | |
| 1733 | + | "post": { | |
| 1734 | + | "operationId": "remember", | |
| 1735 | + | "tags": [ | |
| 1736 | + | "Memory" | |
| 1737 | + | ], | |
| 1738 | + | "summary": "Remember something", | |
| 1739 | + | "description": "Save something to memory that the next agent working here should know: how to build or test, a convention, a decision and why, a trap. scope project is for this codebase; scope workspace is for what holds across all of the workspace's projects, such as \"we use pnpm everywhere\" or where staging lives. One short fact per memory. Every g1t agent run is given memory at its start, pinned first. Never save a secret, key, token or password: text that looks like one is refused. Members of the workspace and g1t's agents only.\n\nText that looks like a key, a token or a password is refused with `invalid`: memory is read by every agent in the workspace. Saving the same text again in the same scope returns the memory already kept.", | |
| 1740 | + | "x-mcp-tool": "remember", | |
| 1741 | + | "security": [ | |
| 1742 | + | { | |
| 1743 | + | "token": [] | |
| 1744 | + | } | |
| 1745 | + | ], | |
| 1746 | + | "parameters": [ | |
| 1747 | + | { | |
| 1748 | + | "name": "owner", | |
| 1749 | + | "in": "path", | |
| 1750 | + | "required": true, | |
| 1751 | + | "schema": { | |
| 1752 | + | "type": "string" | |
| 1753 | + | }, | |
| 1754 | + | "description": "The workspace that owns the repository." | |
| 1755 | + | }, | |
| 1756 | + | { | |
| 1757 | + | "name": "name", | |
| 1758 | + | "in": "path", | |
| 1759 | + | "required": true, | |
| 1760 | + | "schema": { | |
| 1761 | + | "type": "string" | |
| 1762 | + | }, | |
| 1763 | + | "description": "The repository's name." | |
| 1764 | + | } | |
| 1765 | + | ], | |
| 1766 | + | "responses": { | |
| 1767 | + | "200": { | |
| 1768 | + | "description": "Success.", | |
| 1769 | + | "content": { | |
| 1770 | + | "application/json": { | |
| 1771 | + | "schema": {}, | |
| 1772 | + | "example": { | |
| 1773 | + | "id": "mem_01m43v8q2w3e4r5t6y7u8i9o0p", | |
| 1774 | + | "scope": "project", | |
| 1775 | + | "workspace": "syntaqx", | |
| 1776 | + | "repo": { | |
| 1777 | + | "namespace": "syntaqx", | |
| 1778 | + | "name": "hello" | |
| 1779 | + | }, | |
| 1780 | + | "text": "The tests need TZ=UTC or the date tests fail.", | |
| 1781 | + | "kind": "gotcha", | |
| 1782 | + | "source": { | |
| 1783 | + | "kind": "run", | |
| 1784 | + | "runId": "arn_01m43v2c1p9k8d7e6f5a4b3c2d", | |
| 1785 | + | "repo": { | |
| 1786 | + | "namespace": "syntaqx", | |
| 1787 | + | "name": "hello" | |
| 1788 | + | }, | |
| 1789 | + | "number": 14 | |
| 1790 | + | }, | |
| 1791 | + | "createdBy": "g1t-agent", | |
| 1792 | + | "pinned": false, | |
| 1793 | + | "createdAt": "2026-10-01T18:40:12.000Z", | |
| 1794 | + | "updatedAt": "2026-10-01T18:40:12.000Z", | |
| 1795 | + | "lastUsedAt": null | |
| 1796 | + | } | |
| 1797 | + | } | |
| 1798 | + | } | |
| 1799 | + | }, | |
| 1800 | + | "401": { | |
| 1801 | + | "description": "A token is required, or the one sent is not valid.", | |
| 1802 | + | "content": { | |
| 1803 | + | "application/json": { | |
| 1804 | + | "schema": { | |
| 1805 | + | "$ref": "#/components/schemas/Error" | |
| 1806 | + | } | |
| 1807 | + | } | |
| 1808 | + | } | |
| 1809 | + | }, | |
| 1810 | + | "403": { | |
| 1811 | + | "description": "Signed in, but not allowed to do this.", | |
| 1812 | + | "content": { | |
| 1813 | + | "application/json": { | |
| 1814 | + | "schema": { | |
| 1815 | + | "$ref": "#/components/schemas/Error" | |
| 1816 | + | } | |
| 1817 | + | } | |
| 1818 | + | } | |
| 1819 | + | }, | |
| 1820 | + | "404": { | |
| 1821 | + | "description": "It does not exist, or you cannot see it.", | |
| 1822 | + | "content": { | |
| 1823 | + | "application/json": { | |
| 1824 | + | "schema": { | |
| 1825 | + | "$ref": "#/components/schemas/Error" | |
| 1826 | + | } | |
| 1827 | + | } | |
| 1828 | + | } | |
| 1829 | + | }, | |
| 1830 | + | "409": { | |
| 1831 | + | "description": "The request conflicts with the current state.", | |
| 1832 | + | "content": { | |
| 1833 | + | "application/json": { | |
| 1834 | + | "schema": { | |
| 1835 | + | "$ref": "#/components/schemas/Error" | |
| 1836 | + | } | |
| 1837 | + | } | |
| 1838 | + | } | |
| 1839 | + | }, | |
| 1840 | + | "422": { | |
| 1841 | + | "description": "The input is not valid.", | |
| 1842 | + | "content": { | |
| 1843 | + | "application/json": { | |
| 1844 | + | "schema": { | |
| 1845 | + | "$ref": "#/components/schemas/Error" | |
| 1846 | + | } | |
| 1847 | + | } | |
| 1848 | + | } | |
| 1849 | + | } | |
| 1850 | + | }, | |
| 1851 | + | "requestBody": { | |
| 1852 | + | "required": true, | |
| 1853 | + | "content": { | |
| 1854 | + | "application/json": { | |
| 1855 | + | "schema": { | |
| 1856 | + | "type": "object", | |
| 1857 | + | "properties": { | |
| 1858 | + | "text": { | |
| 1859 | + | "type": "string", | |
| 1860 | + | "description": "What to remember, in one or two sentences. At most 1000 characters." | |
| 1861 | + | }, | |
| 1862 | + | "scope": { | |
| 1863 | + | "type": "string", | |
| 1864 | + | "enum": [ | |
| 1865 | + | "project", | |
| 1866 | + | "workspace" | |
| 1867 | + | ], | |
| 1868 | + | "description": "project: about this codebase. workspace: true across the workspace's projects. Defaults to project." | |
| 1869 | + | }, | |
| 1870 | + | "kind": { | |
| 1871 | + | "type": "string", | |
| 1872 | + | "enum": [ | |
| 1873 | + | "fact", | |
| 1874 | + | "convention", | |
| 1875 | + | "decision", | |
| 1876 | + | "gotcha" | |
| 1877 | + | ], | |
| 1878 | + | "description": "Defaults to fact." | |
| 1879 | + | }, | |
| 1880 | + | "from_number": { | |
| 1881 | + | "type": "integer", | |
| 1882 | + | "description": "For an agent: the pull request you are working on, recorded as where it was learned." | |
| 1883 | + | } | |
| 1884 | + | }, | |
| 1885 | + | "required": [ | |
| 1886 | + | "text" | |
| 1887 | + | ] | |
| 1888 | + | }, | |
| 1889 | + | "example": { | |
| 1890 | + | "text": "The tests need TZ=UTC or the date tests fail.", | |
| 1891 | + | "scope": "project", | |
| 1892 | + | "kind": "gotcha", | |
| 1893 | + | "from_number": 14 | |
| 1894 | + | } | |
| 1895 | + | } | |
| 1896 | + | } | |
| 1897 | + | } | |
| 1898 | + | }, | |
| 1899 | + | "get": { | |
| 1900 | + | "operationId": "recall", | |
| 1901 | + | "tags": [ | |
| 1902 | + | "Memory" | |
| 1903 | + | ], | |
| 1904 | + | "summary": "Recall memory", | |
| 1905 | + | "description": "Search what the project and its workspace remember, by words in any order, or list it all without a query. Pinned memories come first, then the most recently used. Members of the workspace and g1t's agents only.\n\nMembers of the workspace only. Each memory returned is marked used, which keeps it near the front of what agents are given.", | |
| 1906 | + | "x-mcp-tool": "recall", | |
| 1907 | + | "security": [ | |
| 1908 | + | { | |
| 1909 | + | "token": [] | |
| 1910 | + | } | |
| 1911 | + | ], | |
| 1912 | + | "parameters": [ | |
| 1913 | + | { | |
| 1914 | + | "name": "owner", | |
| 1915 | + | "in": "path", | |
| 1916 | + | "required": true, | |
| 1917 | + | "schema": { | |
| 1918 | + | "type": "string" | |
| 1919 | + | }, | |
| 1920 | + | "description": "The workspace that owns the repository." | |
| 1921 | + | }, | |
| 1922 | + | { | |
| 1923 | + | "name": "name", | |
| 1924 | + | "in": "path", | |
| 1925 | + | "required": true, | |
| 1926 | + | "schema": { | |
| 1927 | + | "type": "string" | |
| 1928 | + | }, | |
| 1929 | + | "description": "The repository's name." | |
| 1930 | + | }, | |
| 1931 | + | { | |
| 1932 | + | "name": "q", | |
| 1933 | + | "in": "query", | |
| 1934 | + | "required": false, | |
| 1935 | + | "schema": { | |
| 1936 | + | "type": "string" | |
| 1937 | + | }, | |
| 1938 | + | "description": "Words to look for. Leave out for everything." | |
| 1939 | + | }, | |
| 1940 | + | { | |
| 1941 | + | "name": "limit", | |
| 1942 | + | "in": "query", | |
| 1943 | + | "required": false, | |
| 1944 | + | "schema": { | |
| 1945 | + | "type": "integer" | |
| 1946 | + | }, | |
| 1947 | + | "description": "At most 100 of each level; 20 if not given." | |
| 1948 | + | } | |
| 1949 | + | ], | |
| 1950 | + | "responses": { | |
| 1951 | + | "200": { | |
| 1952 | + | "description": "Success.", | |
| 1953 | + | "content": { | |
| 1954 | + | "application/json": { | |
| 1955 | + | "schema": {}, | |
| 1956 | + | "example": { | |
| 1957 | + | "project": [], | |
| 1958 | + | "workspace": [ | |
| 1959 | + | { | |
| 1960 | + | "id": "mem_01m43t0a1s2d3f4g5h6j7k8l9z", | |
| 1961 | + | "scope": "workspace", | |
| 1962 | + | "workspace": "syntaqx", | |
| 1963 | + | "repo": null, | |
| 1964 | + | "text": "We use pnpm everywhere, never npm or yarn.", | |
| 1965 | + | "kind": "convention", | |
| 1966 | + | "source": { | |
| 1967 | + | "kind": "person", | |
| 1968 | + | "runId": null, | |
| 1969 | + | "repo": null, | |
| 1970 | + | "number": null | |
| 1971 | + | }, | |
| 1972 | + | "createdBy": "syntaqx", | |
| 1973 | + | "pinned": true, | |
| 1974 | + | "createdAt": "2026-10-01T18:40:12.000Z", | |
| 1975 | + | "updatedAt": "2026-10-01T18:40:12.000Z", | |
| 1976 | + | "lastUsedAt": null | |
| 1977 | + | } | |
| 1978 | + | ] | |
| 1979 | + | } | |
| 1980 | + | } | |
| 1981 | + | } | |
| 1982 | + | }, | |
| 1983 | + | "401": { | |
| 1984 | + | "description": "A token is required, or the one sent is not valid.", | |
| 1985 | + | "content": { | |
| 1986 | + | "application/json": { | |
| 1987 | + | "schema": { | |
| 1988 | + | "$ref": "#/components/schemas/Error" | |
| 1989 | + | } | |
| 1990 | + | } | |
| 1991 | + | } | |
| 1992 | + | }, | |
| 1993 | + | "403": { | |
| 1994 | + | "description": "Signed in, but not allowed to do this.", | |
| 1995 | + | "content": { | |
| 1996 | + | "application/json": { | |
| 1997 | + | "schema": { | |
| 1998 | + | "$ref": "#/components/schemas/Error" | |
| 1999 | + | } | |
| 2000 | + | } | |
| 2001 | + | } | |
| 2002 | + | }, | |
| 2003 | + | "404": { | |
| 2004 | + | "description": "It does not exist, or you cannot see it.", | |
| 2005 | + | "content": { | |
| 2006 | + | "application/json": { | |
| 2007 | + | "schema": { | |
| 2008 | + | "$ref": "#/components/schemas/Error" | |
| 2009 | + | } | |
| 2010 | + | } | |
| 2011 | + | } | |
| 2012 | + | }, | |
| 2013 | + | "422": { | |
| 2014 | + | "description": "The input is not valid.", | |
| 2015 | + | "content": { | |
| 2016 | + | "application/json": { | |
| 2017 | + | "schema": { | |
| 2018 | + | "$ref": "#/components/schemas/Error" | |
| 2019 | + | } | |
| 2020 | + | } | |
| 2021 | + | } | |
| 2022 | + | } | |
| 2023 | + | }, | |
| 2024 | + | "x-example-query": { | |
| 2025 | + | "q": "pnpm" | |
| 2026 | + | } | |
| 2027 | + | } | |
| 2028 | + | }, | |
| 1724 | 2029 | "/repos/{owner}/{name}/events": { | |
| 1725 | 2030 | "get": { | |
| 1726 | 2031 | "operationId": "list_events", | |
| 10716 | 11021 | "Pull requests" | |
| 10717 | 11022 | ], | |
| 10718 | 11023 | "summary": "Get a pull request", | |
| 10719 | − | "description": "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files.\n\n| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves.", | |
| 11024 | + | "description": "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files.\n\n| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `checks` | The latest run of the acceptance checks against its head, with each command's output. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `statuses` | Commit statuses reported for its head, such as workflow runs. |\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlierChecks` | Earlier runs of the acceptance checks, newest first, without their output. |\n\nChecks are started by g1t, not through the API. They run when a pull request becomes ready for review and again when its head moves.", | |
| 10720 | 11025 | "x-mcp-tool": "get_pull_request", | |
| 10721 | 11026 | "security": [ | |
| 10722 | 11027 | { |
| 1 | + | /** | |
| 2 | + | * Agents at work, wherever they show: the project's Agents page, the | |
| 3 | + | * workspace's fleet, a pull request's Agent panel and the pull request | |
| 4 | + | * list. Stopping and messaging a run go through the project's | |
| 5 | + | * `agents.json` resource route, so every place behaves the same. | |
| 6 | + | */ | |
| 7 | + | import { Bot, CircleSlash, Clock, Coins, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 9 | + | import { Link, useFetcher, useRevalidator } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | type AgentRun, | |
| 13 | + | type AgentRunStatus, | |
| 14 | + | type RunKind, | |
| 15 | + | RUN_KIND_LABEL, | |
| 16 | + | type Stage, | |
| 17 | + | isActiveRun, | |
| 18 | + | isAgentKind, | |
| 19 | + | takesMessages, | |
| 20 | + | } from "@g1t/contracts"; | |
| 21 | + | ||
| 22 | + | import { STAGE_LABEL, StageDots } from "./lifecycle"; | |
| 23 | + | import { Avatar, TimeAgo } from "./ui"; | |
| 24 | + | import { | |
| 25 | + | AlertDialog, | |
| 26 | + | AlertDialogAction, | |
| 27 | + | AlertDialogCancel, | |
| 28 | + | AlertDialogContent, | |
| 29 | + | AlertDialogDescription, | |
| 30 | + | AlertDialogFooter, | |
| 31 | + | AlertDialogHeader, | |
| 32 | + | AlertDialogTitle, | |
| 33 | + | AlertDialogTrigger, | |
| 34 | + | } from "./ui/alert-dialog"; | |
| 35 | + | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 36 | + | ||
| 37 | + | /** How often a page with something running asks again. */ | |
| 38 | + | export const LIVE_MS = 4000; | |
| 39 | + | ||
| 40 | + | /** Revalidates the page every few seconds while `live`, as the merge queue does. */ | |
| 41 | + | export function useLiveRefresh(live: boolean) { | |
| 42 | + | const revalidator = useRevalidator(); | |
| 43 | + | useEffect(() => { | |
| 44 | + | if (!live) return; | |
| 45 | + | const timer = setInterval(() => { | |
| 46 | + | if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate(); | |
| 47 | + | }, LIVE_MS); | |
| 48 | + | return () => clearInterval(timer); | |
| 49 | + | }, [live, revalidator]); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | export function formatCost(usd: number | null | undefined): string | null { | |
| 53 | + | if (usd == null) return null; | |
| 54 | + | if (usd > 0 && usd < 0.01) return "<$0.01"; | |
| 55 | + | return `$${usd.toFixed(2)}`; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | function span(ms: number): string { | |
| 59 | + | const seconds = Math.max(0, Math.floor(ms / 1000)); | |
| 60 | + | const hours = Math.floor(seconds / 3600); | |
| 61 | + | const minutes = Math.floor((seconds % 3600) / 60); | |
| 62 | + | if (hours > 0) return `${hours}h ${minutes}m`; | |
| 63 | + | if (minutes > 0) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; | |
| 64 | + | return `${seconds}s`; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** How long a run has gone on, ticking while it runs. */ | |
| 68 | + | export function Elapsed({ from, to }: { from: string; to?: string | null }) { | |
| 69 | + | const [now, setNow] = useState(() => Date.now()); | |
| 70 | + | useEffect(() => { | |
| 71 | + | if (to) return; | |
| 72 | + | const timer = setInterval(() => setNow(Date.now()), 1000); | |
| 73 | + | return () => clearInterval(timer); | |
| 74 | + | }, [to]); | |
| 75 | + | const end = to ? new Date(to).getTime() : now; | |
| 76 | + | return ( | |
| 77 | + | <span suppressHydrationWarning className="tabular-nums"> | |
| 78 | + | {span(end - new Date(from).getTime())} | |
| 79 | + | </span> | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | const STATUS: Record<AgentRunStatus, { label: string; tone: string; icon: ReactNode }> = { | |
| 84 | + | queued: { label: "Starting", tone: "text-muted border-line", icon: <Clock size={12} /> }, | |
| 85 | + | running: { | |
| 86 | + | label: "Running", | |
| 87 | + | tone: "text-merged border-merged/40 bg-merged/10", | |
| 88 | + | icon: <Loader2 size={12} className="animate-spin" />, | |
| 89 | + | }, | |
| 90 | + | succeeded: { label: "Done", tone: "text-accent border-accent/40 bg-accent/10", icon: <Bot size={12} /> }, | |
| 91 | + | failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <TriangleAlert size={12} /> }, | |
| 92 | + | stopped: { label: "Stopped", tone: "text-warn border-warn/40 bg-warn/10", icon: <OctagonX size={12} /> }, | |
| 93 | + | }; | |
| 94 | + | ||
| 95 | + | export function RunStatusBadge({ status }: { status: AgentRunStatus }) { | |
| 96 | + | const { label, tone, icon } = STATUS[status]; | |
| 97 | + | return ( | |
| 98 | + | <span className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${tone}`}> | |
| 99 | + | {icon} | |
| 100 | + | {label} | |
| 101 | + | </span> | |
| 102 | + | ); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | export function KindLabel({ kind }: { kind: RunKind }) { | |
| 106 | + | return ( | |
| 107 | + | <span className="rounded border border-line px-1.5 py-px font-mono text-[0.6875rem] text-muted"> | |
| 108 | + | {RUN_KIND_LABEL[kind].toLowerCase()} | |
| 109 | + | </span> | |
| 110 | + | ); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** Where a project's agent actions are posted. */ | |
| 114 | + | function actionUrl(run: AgentRun): string { | |
| 115 | + | return `/${run.repo.namespace}/${run.repo.name}/agents.json`; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | type ActionResult = { ok: boolean; notice?: string; error?: string } | undefined; | |
| 119 | + | ||
| 120 | + | /** Stop a run, after saying what that does. */ | |
| 121 | + | export function StopRun({ run, compact }: { run: AgentRun; compact?: boolean }) { | |
| 122 | + | const fetcher = useFetcher<ActionResult>(); | |
| 123 | + | const busy = fetcher.state !== "idle"; | |
| 124 | + | const onPull = run.number != null && isAgentKind(run.kind); | |
| 125 | + | return ( | |
| 126 | + | <> | |
| 127 | + | <AlertDialog> | |
| 128 | + | <AlertDialogTrigger | |
| 129 | + | disabled={busy} | |
| 130 | + | className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-danger/50 hover:text-danger disabled:opacity-50" | |
| 131 | + | > | |
| 132 | + | <Square size={11} /> | |
| 133 | + | {busy ? "Stopping…" : compact ? "Stop" : "Stop run"} | |
| 134 | + | </AlertDialogTrigger> | |
| 135 | + | <AlertDialogContent> | |
| 136 | + | <AlertDialogHeader> | |
| 137 | + | <AlertDialogTitle>Stop this {RUN_KIND_LABEL[run.kind].toLowerCase()} run?</AlertDialogTitle> | |
| 138 | + | <AlertDialogDescription> | |
| 139 | + | Its sandbox is shut down at once and nothing more is pushed. | |
| 140 | + | {onPull && | |
| 141 | + | ` g1t stops seeing #${run.number} through and leaves it for a person; what the agent already pushed stays on it.`} | |
| 142 | + | </AlertDialogDescription> | |
| 143 | + | </AlertDialogHeader> | |
| 144 | + | <AlertDialogFooter> | |
| 145 | + | <AlertDialogCancel>Keep it running</AlertDialogCancel> | |
| 146 | + | <AlertDialogAction | |
| 147 | + | onClick={() => fetcher.submit({ intent: "stop", run: run.id }, { method: "post", action: actionUrl(run) })} | |
| 148 | + | > | |
| 149 | + | Stop | |
| 150 | + | </AlertDialogAction> | |
| 151 | + | </AlertDialogFooter> | |
| 152 | + | </AlertDialogContent> | |
| 153 | + | </AlertDialog> | |
| 154 | + | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} | |
| 155 | + | </> | |
| 156 | + | ); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /** Send the agent on a run's pull request a message. */ | |
| 160 | + | export function MessageRun({ run }: { run: AgentRun }) { | |
| 161 | + | const fetcher = useFetcher<ActionResult>(); | |
| 162 | + | const [open, setOpen] = useState(false); | |
| 163 | + | const live = takesMessages(run.kind) && isActiveRun(run.status); | |
| 164 | + | if (run.number == null) return null; | |
| 165 | + | return ( | |
| 166 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 167 | + | <DialogTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"> | |
| 168 | + | <MessageSquare size={11} /> | |
| 169 | + | Message | |
| 170 | + | </DialogTrigger> | |
| 171 | + | <DialogContent> | |
| 172 | + | <DialogHeader> | |
| 173 | + | <DialogTitle>Message the agent on #{run.number}</DialogTitle> | |
| 174 | + | <DialogDescription> | |
| 175 | + | {live | |
| 176 | + | ? "It reads this at its next step, between tool calls, without starting over." | |
| 177 | + | : "This run does not read messages while it works. Your message waits on the pull request and is given to the agent's next run there."} | |
| 178 | + | </DialogDescription> | |
| 179 | + | </DialogHeader> | |
| 180 | + | <fetcher.Form method="post" action={actionUrl(run)} className="space-y-3"> | |
| 181 | + | <input type="hidden" name="intent" value="message" /> | |
| 182 | + | <input type="hidden" name="number" value={run.number} /> | |
| 183 | + | <textarea | |
| 184 | + | name="body" | |
| 185 | + | required | |
| 186 | + | rows={4} | |
| 187 | + | autoComplete="off" | |
| 188 | + | data-1p-ignore | |
| 189 | + | placeholder="Keep the old flag working too…" | |
| 190 | + | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 191 | + | /> | |
| 192 | + | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 193 | + | {fetcher.data?.ok && fetcher.data.notice && <p className="text-sm text-accent">{fetcher.data.notice}</p>} | |
| 194 | + | <div className="flex justify-end"> | |
| 195 | + | <button | |
| 196 | + | type="submit" | |
| 197 | + | disabled={fetcher.state !== "idle"} | |
| 198 | + | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 199 | + | > | |
| 200 | + | {fetcher.state !== "idle" ? "Sending…" : "Send"} | |
| 201 | + | </button> | |
| 202 | + | </div> | |
| 203 | + | </fetcher.Form> | |
| 204 | + | </DialogContent> | |
| 205 | + | </Dialog> | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /** What a run is on, as a link: its pull request, or the plan it writes. */ | |
| 210 | + | function RunTarget({ run, showRepo }: { run: AgentRun; showRepo?: boolean }) { | |
| 211 | + | const base = `/${run.repo.namespace}/${run.repo.name}`; | |
| 212 | + | const repo = showRepo ? ( | |
| 213 | + | <Link to={base} className="font-mono text-xs text-faint hover:text-muted"> | |
| 214 | + | {run.repo.namespace}/{run.repo.name} | |
| 215 | + | </Link> | |
| 216 | + | ) : null; | |
| 217 | + | if (run.number != null) { | |
| 218 | + | return ( | |
| 219 | + | <span className="flex min-w-0 flex-wrap items-baseline gap-x-2"> | |
| 220 | + | {repo} | |
| 221 | + | <Link to={`${base}/pull/${run.number}`} prefetch="intent" className="min-w-0 truncate font-medium hover:text-accent"> | |
| 222 | + | {run.title ?? "Pull request"} <span className="font-normal text-faint">#{run.number}</span> | |
| 223 | + | </Link> | |
| 224 | + | </span> | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | return ( | |
| 228 | + | <span className="flex min-w-0 flex-wrap items-baseline gap-x-2"> | |
| 229 | + | {repo} | |
| 230 | + | <Link to={`${base}/plans`} className="min-w-0 truncate font-medium hover:text-accent"> | |
| 231 | + | {run.title ?? "A plan"} | |
| 232 | + | </Link> | |
| 233 | + | </span> | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /** One run, live or finished, with what can be done about it. */ | |
| 238 | + | export function RunCard({ run, member, showRepo }: { run: AgentRun; member: boolean; showRepo?: boolean }) { | |
| 239 | + | const base = `/${run.repo.namespace}/${run.repo.name}`; | |
| 240 | + | const active = isActiveRun(run.status); | |
| 241 | + | const cost = formatCost(run.costUsd); | |
| 242 | + | return ( | |
| 243 | + | <li | |
| 244 | + | className={`rounded-xl border bg-surface p-4 transition-colors hover:border-line-strong ${ | |
| 245 | + | active ? "border-merged/30" : "border-line" | |
| 246 | + | }`} | |
| 247 | + | > | |
| 248 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 249 | + | <Avatar name={run.agent} size={22} /> | |
| 250 | + | <span className="text-sm font-medium">{run.agent}</span> | |
| 251 | + | <KindLabel kind={run.kind} /> | |
| 252 | + | <span className="min-w-0 grow"> | |
| 253 | + | <RunTarget run={run} showRepo={showRepo} /> | |
| 254 | + | </span> | |
| 255 | + | <RunStatusBadge status={run.status} /> | |
| 256 | + | </div> | |
| 257 | + | {run.step && ( | |
| 258 | + | <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={run.step}> | |
| 259 | + | {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 260 | + | {run.step} | |
| 261 | + | </p> | |
| 262 | + | )} | |
| 263 | + | {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>} | |
| 264 | + | <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| 265 | + | <span className="flex items-center gap-1"> | |
| 266 | + | <Clock size={12} /> | |
| 267 | + | {run.startedAt || active ? <Elapsed from={run.startedAt ?? run.createdAt} to={run.finishedAt} /> : "—"} | |
| 268 | + | </span> | |
| 269 | + | {cost && ( | |
| 270 | + | <span className="flex items-center gap-1" title={active ? "So far, as the harness reports it" : "As the harness reported it"}> | |
| 271 | + | <Coins size={12} /> | |
| 272 | + | {cost} | |
| 273 | + | {active && " so far"} | |
| 274 | + | </span> | |
| 275 | + | )} | |
| 276 | + | {run.model && <span className="font-mono">{run.model}</span>} | |
| 277 | + | {run.startedBy && <span>started by {run.startedBy}</span>} | |
| 278 | + | <span> | |
| 279 | + | <TimeAgo at={run.createdAt} /> | |
| 280 | + | </span> | |
| 281 | + | <span className="grow" /> | |
| 282 | + | <Link to={`${base}/agents/runs/${run.id}`} className="hover:text-fg"> | |
| 283 | + | {run.stepCount} {run.stepCount === 1 ? "step" : "steps"} | |
| 284 | + | </Link> | |
| 285 | + | {run.number != null && ( | |
| 286 | + | <Link to={`${base}/sessions/${run.number}`} className="hover:text-fg"> | |
| 287 | + | Session | |
| 288 | + | </Link> | |
| 289 | + | )} | |
| 290 | + | {member && active && <MessageRun run={run} />} | |
| 291 | + | {member && active && <StopRun run={run} compact />} | |
| 292 | + | </div> | |
| 293 | + | </li> | |
| 294 | + | ); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /** The runs that are on now, newest first, then the rest. */ | |
| 298 | + | export function splitRuns(runs: AgentRun[]): { live: AgentRun[]; done: AgentRun[] } { | |
| 299 | + | return { | |
| 300 | + | live: runs.filter((run) => isActiveRun(run.status)), | |
| 301 | + | done: runs.filter((run) => !isActiveRun(run.status)), | |
| 302 | + | }; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | type Live = { runs: AgentRun[]; member: boolean }; | |
| 306 | + | ||
| 307 | + | /** | |
| 308 | + | * The runs of a project, fetched from its `agents.json`, every few seconds | |
| 309 | + | * while any is running. For pages whose own loader does not have them. | |
| 310 | + | */ | |
| 311 | + | export function useRuns(owner: string, repo: string, query: Record<string, string>): Live | null { | |
| 312 | + | const fetcher = useFetcher<Live>(); | |
| 313 | + | const search = new URLSearchParams(query).toString(); | |
| 314 | + | const url = `/${owner}/${repo}/agents.json?${search}`; | |
| 315 | + | const { load } = fetcher; | |
| 316 | + | useEffect(() => { | |
| 317 | + | load(url); | |
| 318 | + | }, [load, url]); | |
| 319 | + | const live = fetcher.data?.runs.some((run) => isActiveRun(run.status)) ?? false; | |
| 320 | + | useEffect(() => { | |
| 321 | + | if (!live) return; | |
| 322 | + | const timer = setInterval(() => { | |
| 323 | + | if (document.visibilityState === "visible") load(url); | |
| 324 | + | }, LIVE_MS); | |
| 325 | + | return () => clearInterval(timer); | |
| 326 | + | }, [live, load, url]); | |
| 327 | + | return fetcher.data ?? null; | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /** | |
| 331 | + | * The agent on a pull request, near the top of its page: who is working | |
| 332 | + | * on it, at what stage, what it is doing this minute, for how long and at | |
| 333 | + | * what cost, with its session, and Stop and Message for members. | |
| 334 | + | */ | |
| 335 | + | export function AgentPanel({ | |
| 336 | + | owner, | |
| 337 | + | repo, | |
| 338 | + | number, | |
| 339 | + | stage, | |
| 340 | + | }: { | |
| 341 | + | owner: string; | |
| 342 | + | repo: string; | |
| 343 | + | number: number; | |
| 344 | + | stage?: Stage | null; | |
| 345 | + | }) { | |
| 346 | + | const data = useRuns(owner, repo, { number: String(number), limit: "5" }); | |
| 347 | + | const runs = data?.runs ?? []; | |
| 348 | + | const member = data?.member ?? false; | |
| 349 | + | const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0]; | |
| 350 | + | if (!current) return null; | |
| 351 | + | const active = isActiveRun(current.status); | |
| 352 | + | const base = `/${owner}/${repo}`; | |
| 353 | + | const cost = formatCost(current.costUsd); | |
| 354 | + | const spent = formatCost(runs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0) || null); | |
| 355 | + | return ( | |
| 356 | + | <section | |
| 357 | + | aria-label="Agent" | |
| 358 | + | className={`mt-4 rounded-2xl bg-surface p-4 ring-1 ${active ? "ring-merged/40" : "ring-line"}`} | |
| 359 | + | > | |
| 360 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 361 | + | <Avatar name={current.agent} size={24} /> | |
| 362 | + | <p className="text-sm"> | |
| 363 | + | <span className="font-medium">{current.agent}</span>{" "} | |
| 364 | + | <span className="text-muted"> | |
| 365 | + | {active | |
| 366 | + | ? `is ${RUN_KIND_LABEL[current.kind].toLowerCase()}` | |
| 367 | + | : `${current.status === "succeeded" ? "finished" : current.status} its ${RUN_KIND_LABEL[current.kind].toLowerCase()} run`} | |
| 368 | + | </span> | |
| 369 | + | </p> | |
| 370 | + | {stage && ( | |
| 371 | + | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 372 | + | <StageDots stage={stage} /> | |
| 373 | + | {STAGE_LABEL[stage]} | |
| 374 | + | </span> | |
| 375 | + | )} | |
| 376 | + | <span className="grow" /> | |
| 377 | + | <RunStatusBadge status={current.status} /> | |
| 378 | + | </div> | |
| 379 | + | {current.step && ( | |
| 380 | + | <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={current.step}> | |
| 381 | + | {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 382 | + | {current.step} | |
| 383 | + | </p> | |
| 384 | + | )} | |
| 385 | + | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| 386 | + | <span className="flex items-center gap-1"> | |
| 387 | + | <Clock size={12} /> | |
| 388 | + | <Elapsed from={current.startedAt ?? current.createdAt} to={current.finishedAt} /> | |
| 389 | + | </span> | |
| 390 | + | {cost && ( | |
| 391 | + | <span className="flex items-center gap-1"> | |
| 392 | + | <Coins size={12} /> | |
| 393 | + | {cost} | |
| 394 | + | {active ? " so far" : ""} | |
| 395 | + | {runs.length > 1 && spent && ` · ${spent} over ${runs.length} runs`} | |
| 396 | + | </span> | |
| 397 | + | )} | |
| 398 | + | {current.model && <span className="font-mono">{current.model}</span>} | |
| 399 | + | <span className="grow" /> | |
| 400 | + | <Link to={`${base}/agents/runs/${current.id}`} className="hover:text-fg"> | |
| 401 | + | Run | |
| 402 | + | </Link> | |
| 403 | + | <Link to={`${base}/sessions/${number}`} className="hover:text-fg"> | |
| 404 | + | Session | |
| 405 | + | </Link> | |
| 406 | + | {member && active && <MessageRun run={current} />} | |
| 407 | + | {member && active && <StopRun run={current} />} | |
| 408 | + | </div> | |
| 409 | + | </section> | |
| 410 | + | ); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /** A small "agent working" mark for a row of the pull request list. */ | |
| 414 | + | export function AgentBadge({ run }: { run: AgentRun | undefined }) { | |
| 415 | + | if (!run) return null; | |
| 416 | + | return ( | |
| 417 | + | <span | |
| 418 | + | title={run.step ?? undefined} | |
| 419 | + | className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged" | |
| 420 | + | > | |
| 421 | + | <Loader2 size={11} className="animate-spin" /> | |
| 422 | + | {RUN_KIND_LABEL[run.kind]} | |
| 423 | + | </span> | |
| 424 | + | ); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | /** The active run on each pull request of a project, by number. */ | |
| 428 | + | export function useActiveRuns(owner: string, repo: string): Map<number, AgentRun> { | |
| 429 | + | const data = useRuns(owner, repo, { active: "1", limit: "100" }); | |
| 430 | + | return useMemo(() => { | |
| 431 | + | const byNumber = new Map<number, AgentRun>(); | |
| 432 | + | for (const run of data?.runs ?? []) { | |
| 433 | + | if (run.number != null && !byNumber.has(run.number)) byNumber.set(run.number, run); | |
| 434 | + | } | |
| 435 | + | return byNumber; | |
| 436 | + | }, [data]); | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /** Nothing running, said plainly. */ | |
| 440 | + | export function Idle({ children }: { children: ReactNode }) { | |
| 441 | + | return ( | |
| 442 | + | <p className="flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted"> | |
| 443 | + | <CircleSlash size={15} className="text-faint" /> | |
| 444 | + | {children} | |
| 445 | + | </p> | |
| 446 | + | ); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | /** | |
| 450 | + | * What the agent on a pull request is doing this minute, as one line, for | |
| 451 | + | * an issue's sidebar: which agent picked the issue up, and where it is. | |
| 452 | + | */ | |
| 453 | + | export function AgentStepLine({ owner, repo, number }: { owner: string; repo: string; number: number }) { | |
| 454 | + | const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" }); | |
| 455 | + | const run = data?.runs[0]; | |
| 456 | + | if (!run?.step) return null; | |
| 457 | + | return ( | |
| 458 | + | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={run.step}> | |
| 459 | + | {RUN_KIND_LABEL[run.kind]}: {run.step} | |
| 460 | + | </span> | |
| 461 | + | ); | |
| 462 | + | } |
| 1 | + | /** | |
| 2 | + | * Memory, as a project's and a workspace's pages show it: what agents and | |
| 3 | + | * people learned, each with where it came from, to pin, edit or forget. | |
| 4 | + | * Both pages post to their own action with the intents in `memoryAction`. | |
| 5 | + | */ | |
| 6 | + | import { Bot, Brain, Pencil, Pin, PinOff, Trash2, User } from "lucide-react"; | |
| 7 | + | import { useEffect, useRef, useState } from "react"; | |
| 8 | + | import { Link, useFetcher } from "react-router"; | |
| 9 | + | ||
| 10 | + | import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { TimeAgo } from "./ui"; | |
| 13 | + | import { | |
| 14 | + | AlertDialog, | |
| 15 | + | AlertDialogAction, | |
| 16 | + | AlertDialogCancel, | |
| 17 | + | AlertDialogContent, | |
| 18 | + | AlertDialogDescription, | |
| 19 | + | AlertDialogFooter, | |
| 20 | + | AlertDialogHeader, | |
| 21 | + | AlertDialogTitle, | |
| 22 | + | AlertDialogTrigger, | |
| 23 | + | } from "./ui/alert-dialog"; | |
| 24 | + | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 25 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 26 | + | import { Switch } from "./ui/switch"; | |
| 27 | + | ||
| 28 | + | const KIND: Record<MemoryKind, { label: string; about: string; tone: string }> = { | |
| 29 | + | fact: { label: "Fact", about: "How something is", tone: "text-info border-info/40" }, | |
| 30 | + | convention: { label: "Convention", about: "How things are done here", tone: "text-accent border-accent/40" }, | |
| 31 | + | decision: { label: "Decision", about: "Something chosen, and why", tone: "text-merged border-merged/40" }, | |
| 32 | + | gotcha: { label: "Gotcha", about: "A trap to avoid", tone: "text-warn border-warn/40" }, | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | const TEXTAREA = | |
| 36 | + | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 37 | + | ||
| 38 | + | type Done = { ok: boolean; error?: string } | undefined; | |
| 39 | + | ||
| 40 | + | function KindBadge({ kind }: { kind: MemoryKind }) { | |
| 41 | + | return ( | |
| 42 | + | <span className={`inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[0.6875rem] ${KIND[kind].tone}`}> | |
| 43 | + | {KIND[kind].label} | |
| 44 | + | </span> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function KindSelect({ value }: { value?: MemoryKind }) { | |
| 49 | + | return ( | |
| 50 | + | <Select name="kind" defaultValue={value ?? "fact"}> | |
| 51 | + | <SelectTrigger size="sm" className="w-40"> | |
| 52 | + | <SelectValue /> | |
| 53 | + | </SelectTrigger> | |
| 54 | + | <SelectContent> | |
| 55 | + | {MEMORY_KINDS.map((kind) => ( | |
| 56 | + | <SelectItem key={kind} value={kind} description={KIND[kind].about}> | |
| 57 | + | {KIND[kind].label} | |
| 58 | + | </SelectItem> | |
| 59 | + | ))} | |
| 60 | + | </SelectContent> | |
| 61 | + | </Select> | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** Where a memory came from, linked where it can be. */ | |
| 66 | + | function Source({ memory }: { memory: Memory }) { | |
| 67 | + | const { source } = memory; | |
| 68 | + | const repo = source.repo; | |
| 69 | + | const where = | |
| 70 | + | repo && source.number != null ? ( | |
| 71 | + | <> | |
| 72 | + | {" "} | |
| 73 | + | on{" "} | |
| 74 | + | <Link to={`/${repo.namespace}/${repo.name}/pull/${source.number}`} className="hover:text-fg"> | |
| 75 | + | {memory.scope === "workspace" && `${repo.name}`}#{source.number} | |
| 76 | + | </Link> | |
| 77 | + | </> | |
| 78 | + | ) : repo && memory.scope === "workspace" ? ( | |
| 79 | + | <> | |
| 80 | + | {" "} | |
| 81 | + | in <Link to={`/${repo.namespace}/${repo.name}`} className="hover:text-fg">{repo.name}</Link> | |
| 82 | + | </> | |
| 83 | + | ) : null; | |
| 84 | + | if (source.kind === "person") { | |
| 85 | + | return ( | |
| 86 | + | <span className="inline-flex items-center gap-1"> | |
| 87 | + | <User size={11} /> | |
| 88 | + | {memory.createdBy} | |
| 89 | + | {where} | |
| 90 | + | </span> | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | return ( | |
| 94 | + | <span className="inline-flex items-center gap-1"> | |
| 95 | + | <Bot size={11} /> | |
| 96 | + | {source.runId && repo ? ( | |
| 97 | + | <Link to={`/${repo.namespace}/${repo.name}/agents/runs/${source.runId}`} className="hover:text-fg"> | |
| 98 | + | {memory.createdBy}'s run | |
| 99 | + | </Link> | |
| 100 | + | ) : ( | |
| 101 | + | memory.createdBy | |
| 102 | + | )} | |
| 103 | + | {where} | |
| 104 | + | </span> | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | function EditMemory({ memory, action }: { memory: Memory; action: string }) { | |
| 109 | + | const fetcher = useFetcher<Done>(); | |
| 110 | + | const [open, setOpen] = useState(false); | |
| 111 | + | useEffect(() => { | |
| 112 | + | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); | |
| 113 | + | }, [fetcher.state, fetcher.data]); | |
| 114 | + | return ( | |
| 115 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 116 | + | <DialogTrigger aria-label="Edit" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg"> | |
| 117 | + | <Pencil size={13} /> | |
| 118 | + | </DialogTrigger> | |
| 119 | + | <DialogContent> | |
| 120 | + | <DialogHeader> | |
| 121 | + | <DialogTitle>Edit memory</DialogTitle> | |
| 122 | + | <DialogDescription>Agents get the new wording from their next run on.</DialogDescription> | |
| 123 | + | </DialogHeader> | |
| 124 | + | <fetcher.Form method="post" action={action} className="space-y-3"> | |
| 125 | + | <input type="hidden" name="intent" value="update" /> | |
| 126 | + | <input type="hidden" name="id" value={memory.id} /> | |
| 127 | + | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 128 | + | <div className="flex items-center justify-between gap-3"> | |
| 129 | + | <KindSelect value={memory.kind} /> | |
| 130 | + | <button | |
| 131 | + | type="submit" | |
| 132 | + | disabled={fetcher.state !== "idle"} | |
| 133 | + | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 134 | + | > | |
| 135 | + | Save | |
| 136 | + | </button> | |
| 137 | + | </div> | |
| 138 | + | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 139 | + | </fetcher.Form> | |
| 140 | + | </DialogContent> | |
| 141 | + | </Dialog> | |
| 142 | + | ); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | function MemoryItem({ memory, action, editable }: { memory: Memory; action: string; editable: boolean }) { | |
| 146 | + | const fetcher = useFetcher<Done>(); | |
| 147 | + | const pinned = fetcher.formData ? fetcher.formData.get("pinned") === "true" : memory.pinned; | |
| 148 | + | const gone = fetcher.formData?.get("intent") === "delete"; | |
| 149 | + | if (gone) return null; | |
| 150 | + | return ( | |
| 151 | + | <li className="group flex gap-3 px-4 py-3"> | |
| 152 | + | <div className="min-w-0 grow"> | |
| 153 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 154 | + | <KindBadge kind={memory.kind} /> | |
| 155 | + | {pinned && ( | |
| 156 | + | <span className="inline-flex items-center gap-1 text-[0.6875rem] text-accent"> | |
| 157 | + | <Pin size={11} /> | |
| 158 | + | pinned | |
| 159 | + | </span> | |
| 160 | + | )} | |
| 161 | + | </div> | |
| 162 | + | <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p> | |
| 163 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint"> | |
| 164 | + | <Source memory={memory} /> | |
| 165 | + | <span> | |
| 166 | + | added <TimeAgo at={memory.createdAt} /> | |
| 167 | + | </span> | |
| 168 | + | <span>{memory.lastUsedAt ? <>given to an agent <TimeAgo at={memory.lastUsedAt} /></> : "not given to an agent yet"}</span> | |
| 169 | + | </p> | |
| 170 | + | {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 171 | + | </div> | |
| 172 | + | {editable && ( | |
| 173 | + | <div className="flex shrink-0 items-start gap-0.5"> | |
| 174 | + | <button | |
| 175 | + | type="button" | |
| 176 | + | aria-label={pinned ? "Unpin" : "Pin"} | |
| 177 | + | title={pinned ? "Unpin" : "Pin: given to every agent first"} | |
| 178 | + | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 179 | + | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 180 | + | > | |
| 181 | + | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 182 | + | </button> | |
| 183 | + | <EditMemory memory={memory} action={action} /> | |
| 184 | + | <AlertDialog> | |
| 185 | + | <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger"> | |
| 186 | + | <Trash2 size={13} /> | |
| 187 | + | </AlertDialogTrigger> | |
| 188 | + | <AlertDialogContent> | |
| 189 | + | <AlertDialogHeader> | |
| 190 | + | <AlertDialogTitle>Forget this?</AlertDialogTitle> | |
| 191 | + | <AlertDialogDescription> | |
| 192 | + | No agent is given it again. This cannot be undone. | |
| 193 | + | </AlertDialogDescription> | |
| 194 | + | </AlertDialogHeader> | |
| 195 | + | <AlertDialogFooter> | |
| 196 | + | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 197 | + | <AlertDialogAction onClick={() => fetcher.submit({ intent: "delete", id: memory.id }, { method: "post", action })}> | |
| 198 | + | Forget | |
| 199 | + | </AlertDialogAction> | |
| 200 | + | </AlertDialogFooter> | |
| 201 | + | </AlertDialogContent> | |
| 202 | + | </AlertDialog> | |
| 203 | + | </div> | |
| 204 | + | )} | |
| 205 | + | </li> | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | export function MemoryList({ | |
| 210 | + | memories, | |
| 211 | + | action, | |
| 212 | + | editable, | |
| 213 | + | empty, | |
| 214 | + | }: { | |
| 215 | + | memories: Memory[]; | |
| 216 | + | action: string; | |
| 217 | + | editable: boolean; | |
| 218 | + | empty: string; | |
| 219 | + | }) { | |
| 220 | + | if (memories.length === 0) { | |
| 221 | + | return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">{empty}</p>; | |
| 222 | + | } | |
| 223 | + | return ( | |
| 224 | + | <ul className="divide-y divide-line rounded-xl border border-line bg-surface"> | |
| 225 | + | {memories.map((memory) => ( | |
| 226 | + | <MemoryItem key={memory.id} memory={memory} action={action} editable={editable} /> | |
| 227 | + | ))} | |
| 228 | + | </ul> | |
| 229 | + | ); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /** A form to add a memory at one level. */ | |
| 233 | + | export function AddMemory({ scope, action, placeholder }: { scope: MemoryScope; action: string; placeholder: string }) { | |
| 234 | + | const fetcher = useFetcher<Done>(); | |
| 235 | + | const form = useRef<HTMLFormElement>(null); | |
| 236 | + | useEffect(() => { | |
| 237 | + | if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset(); | |
| 238 | + | }, [fetcher.state, fetcher.data]); | |
| 239 | + | return ( | |
| 240 | + | <fetcher.Form ref={form} method="post" action={action} className="rounded-xl border border-line bg-surface p-4"> | |
| 241 | + | <input type="hidden" name="intent" value="add" /> | |
| 242 | + | <input type="hidden" name="scope" value={scope} /> | |
| 243 | + | <label className="flex items-center gap-2 text-sm font-medium"> | |
| 244 | + | <Brain size={15} className="text-merged" /> | |
| 245 | + | Add to {scope === "workspace" ? "the workspace's" : "this project's"} memory | |
| 246 | + | </label> | |
| 247 | + | <textarea name="text" required rows={2} maxLength={1000} placeholder={placeholder} className={`${TEXTAREA} mt-3`} /> | |
| 248 | + | <div className="mt-3 flex flex-wrap items-center gap-3"> | |
| 249 | + | <KindSelect /> | |
| 250 | + | <label className="flex items-center gap-2 text-xs text-muted"> | |
| 251 | + | <Switch name="pinned" value="true" size="sm" /> | |
| 252 | + | Pin it | |
| 253 | + | </label> | |
| 254 | + | <span className="grow" /> | |
| 255 | + | <button | |
| 256 | + | type="submit" | |
| 257 | + | disabled={fetcher.state !== "idle"} | |
| 258 | + | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 259 | + | > | |
| 260 | + | {fetcher.state !== "idle" ? "Adding…" : "Add"} | |
| 261 | + | </button> | |
| 262 | + | </div> | |
| 263 | + | {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>} | |
| 264 | + | <p className="mt-2 text-xs text-faint"> | |
| 265 | + | One fact, convention, decision or gotcha each. Never a secret: say where it lives instead, and | |
| 266 | + | g1t refuses anything that looks like a key or a token. | |
| 267 | + | </p> | |
| 268 | + | </fetcher.Form> | |
| 269 | + | ); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | type MemoryApi = { | |
| 273 | + | addMemory(actor: Actor, workspace: string, memory: { scope: MemoryScope; repo?: { namespace: string; name: string } | null; text: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>; | |
| 274 | + | updateMemory(actor: Actor, workspace: string, id: string, change: { text?: string; kind?: MemoryKind; pinned?: boolean }): Promise<Result<Memory>>; | |
| 275 | + | deleteMemory(actor: Actor, workspace: string, id: string): Promise<Result<boolean>>; | |
| 276 | + | }; | |
| 277 | + | ||
| 278 | + | /** The action both memory pages share: add, update and delete. */ | |
| 279 | + | export async function memoryAction( | |
| 280 | + | api: MemoryApi, | |
| 281 | + | actor: Actor, | |
| 282 | + | workspace: string, | |
| 283 | + | repo: { namespace: string; name: string } | null, | |
| 284 | + | form: FormData, | |
| 285 | + | ): Promise<Done> { | |
| 286 | + | const intent = String(form.get("intent") ?? ""); | |
| 287 | + | const kindValue = String(form.get("kind") ?? ""); | |
| 288 | + | const kind = MEMORY_KINDS.includes(kindValue as MemoryKind) ? (kindValue as MemoryKind) : undefined; | |
| 289 | + | if (intent === "add") { | |
| 290 | + | const scope: MemoryScope = form.get("scope") === "workspace" ? "workspace" : "project"; | |
| 291 | + | const added = await api.addMemory(actor, workspace, { | |
| 292 | + | scope, | |
| 293 | + | repo, | |
| 294 | + | text: String(form.get("text") ?? ""), | |
| 295 | + | kind, | |
| 296 | + | pinned: form.get("pinned") === "true", | |
| 297 | + | }); | |
| 298 | + | return added.ok ? { ok: true } : { ok: false, error: added.error.message }; | |
| 299 | + | } | |
| 300 | + | if (intent === "update") { | |
| 301 | + | const pinned = form.get("pinned"); | |
| 302 | + | const text = form.get("text"); | |
| 303 | + | const changed = await api.updateMemory(actor, workspace, String(form.get("id") ?? ""), { | |
| 304 | + | text: text == null ? undefined : String(text), | |
| 305 | + | kind, | |
| 306 | + | pinned: pinned == null ? undefined : pinned === "true", | |
| 307 | + | }); | |
| 308 | + | return changed.ok ? { ok: true } : { ok: false, error: changed.error.message }; | |
| 309 | + | } | |
| 310 | + | if (intent === "delete") { | |
| 311 | + | const gone = await api.deleteMemory(actor, workspace, String(form.get("id") ?? "")); | |
| 312 | + | return gone.ok ? { ok: true } : { ok: false, error: gone.error.message }; | |
| 313 | + | } | |
| 314 | + | return { ok: false, error: "Unknown action." }; | |
| 315 | + | } |
| 1 | + | /** | |
| 2 | + | * The parts of a pull request's merge box that say what stands between it | |
| 3 | + | * and a merge: every check, with what a failed one printed, and whether | |
| 4 | + | * the change merges cleanly into its target, with what to do when not. | |
| 5 | + | */ | |
| 6 | + | import { | |
| 7 | + | Check, | |
| 8 | + | ChevronRight, | |
| 9 | + | CircleAlert, | |
| 10 | + | CircleCheck, | |
| 11 | + | CircleX, | |
| 12 | + | Copy, | |
| 13 | + | GitCommitHorizontal, | |
| 14 | + | History, | |
| 15 | + | LoaderCircle, | |
| 16 | + | RotateCw, | |
| 17 | + | Sparkles, | |
| 18 | + | Terminal, | |
| 19 | + | TriangleAlert, | |
| 20 | + | } from "lucide-react"; | |
| 21 | + | import { useMemo, useState } from "react"; | |
| 22 | + | import { Form, Link } from "react-router"; | |
| 23 | + | ||
| 24 | + | import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, Pull } from "@g1t/contracts"; | |
| 25 | + | ||
| 26 | + | import { duration } from "./actions"; | |
| 27 | + | import { Button, CopyLine, ErrorText, TimeAgo } from "./ui"; | |
| 28 | + | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 29 | + | ||
| 30 | + | // --- Output --------------------------------------------------------------- | |
| 31 | + | ||
| 32 | + | /** What the sandbox puts first when it kept only the end of the output. */ | |
| 33 | + | const CUT_SHORT = "… (earlier output not shown)"; | |
| 34 | + | ||
| 35 | + | /** One run of text in one style, from a line with ANSI colour codes. */ | |
| 36 | + | type Segment = { text: string; className: string }; | |
| 37 | + | ||
| 38 | + | const ANSI_COLOURS: Record<number, string> = { | |
| 39 | + | 30: "text-faint", | |
| 40 | + | 31: "text-danger", | |
| 41 | + | 32: "text-accent", | |
| 42 | + | 33: "text-warn", | |
| 43 | + | 34: "text-info", | |
| 44 | + | 35: "text-merged", | |
| 45 | + | 36: "text-info", | |
| 46 | + | 37: "text-fg", | |
| 47 | + | 90: "text-faint", | |
| 48 | + | 91: "text-danger", | |
| 49 | + | 92: "text-accent", | |
| 50 | + | 93: "text-warn", | |
| 51 | + | 94: "text-info", | |
| 52 | + | 95: "text-merged", | |
| 53 | + | 96: "text-info", | |
| 54 | + | 97: "text-fg", | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | /** | |
| 58 | + | * A line's text split where its colour changes. Colours and bold are kept; | |
| 59 | + | * every other escape sequence (cursor movement, erasing, links) is dropped. | |
| 60 | + | */ | |
| 61 | + | export function ansiSegments(line: string): Segment[] { | |
| 62 | + | const segments: Segment[] = []; | |
| 63 | + | let colour = ""; | |
| 64 | + | let bold = false; | |
| 65 | + | // SGR sequences are kept apart; anything else that starts with ESC goes. | |
| 66 | + | const pattern = /\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g; | |
| 67 | + | let at = 0; | |
| 68 | + | const push = (text: string) => { | |
| 69 | + | if (text) segments.push({ text, className: [colour, bold ? "font-semibold" : ""].filter(Boolean).join(" ") }); | |
| 70 | + | }; | |
| 71 | + | for (const match of line.matchAll(pattern)) { | |
| 72 | + | push(line.slice(at, match.index)); | |
| 73 | + | at = match.index + match[0].length; | |
| 74 | + | if (match[1] === undefined) continue; | |
| 75 | + | const codes = match[1] === "" ? [0] : match[1].split(";").map(Number); | |
| 76 | + | for (const code of codes) { | |
| 77 | + | if (code === 0) { | |
| 78 | + | colour = ""; | |
| 79 | + | bold = false; | |
| 80 | + | } else if (code === 1) bold = true; | |
| 81 | + | else if (code === 22) bold = false; | |
| 82 | + | else if (code === 39) colour = ""; | |
| 83 | + | else if (ANSI_COLOURS[code]) colour = ANSI_COLOURS[code]; | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | push(line.slice(at)); | |
| 87 | + | return segments; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** Text without any ANSI escape sequences, for copying. */ | |
| 91 | + | export function stripAnsi(text: string): string { | |
| 92 | + | return ansiSegments(text.replace(/\r\n/g, "\n")) | |
| 93 | + | .map((segment) => segment.text) | |
| 94 | + | .join(""); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** Lines as a terminal would show them: a carriage return starts the line over. */ | |
| 98 | + | function terminalLines(text: string): string[] { | |
| 99 | + | return text | |
| 100 | + | .replace(/\r\n/g, "\n") | |
| 101 | + | .split("\n") | |
| 102 | + | .map((line) => { | |
| 103 | + | const parts = line.split("\r"); | |
| 104 | + | return parts[parts.length - 1] || parts.filter(Boolean).pop() || ""; | |
| 105 | + | }); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /** | |
| 109 | + | * A command's output as a log: numbered lines, colours rendered, a copy | |
| 110 | + | * button, and a note when only the end of it was kept. | |
| 111 | + | */ | |
| 112 | + | export function LogViewer({ text }: { text: string }) { | |
| 113 | + | const cut = text.startsWith(CUT_SHORT); | |
| 114 | + | const body = cut ? text.slice(CUT_SHORT.length).replace(/^\n/, "") : text; | |
| 115 | + | const lines = useMemo(() => { | |
| 116 | + | const all = terminalLines(body); | |
| 117 | + | while (all.length > 0 && all[all.length - 1].trim() === "") all.pop(); | |
| 118 | + | return all; | |
| 119 | + | }, [body]); | |
| 120 | + | const [copied, setCopied] = useState(false); | |
| 121 | + | if (lines.length === 0) { | |
| 122 | + | return <p className="px-3 py-2 font-mono text-xs text-faint">No output.</p>; | |
| 123 | + | } | |
| 124 | + | return ( | |
| 125 | + | <div className="relative"> | |
| 126 | + | <button | |
| 127 | + | type="button" | |
| 128 | + | aria-label="Copy the output" | |
| 129 | + | title="Copy the output" | |
| 130 | + | className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg" | |
| 131 | + | onClick={() => { | |
| 132 | + | void navigator.clipboard.writeText(stripAnsi(body)); | |
| 133 | + | setCopied(true); | |
| 134 | + | setTimeout(() => setCopied(false), 1500); | |
| 135 | + | }} | |
| 136 | + | > | |
| 137 | + | {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />} | |
| 138 | + | </button> | |
| 139 | + | {cut && ( | |
| 140 | + | <p className="border-b border-line px-3 py-1.5 text-xs text-faint"> | |
| 141 | + | Showing the end of the output. Earlier lines were not kept. | |
| 142 | + | </p> | |
| 143 | + | )} | |
| 144 | + | <div className="max-h-96 overflow-auto py-1.5 font-mono text-xs leading-5"> | |
| 145 | + | {lines.map((line, index) => ( | |
| 146 | + | <div key={index} className="flex gap-3 pr-12 pl-2 hover:bg-raised/40"> | |
| 147 | + | <span className="w-9 shrink-0 text-right text-faint/70 select-none">{index + 1}</span> | |
| 148 | + | <span className="min-w-0 break-all whitespace-pre-wrap text-fg/85"> | |
| 149 | + | {ansiSegments(line).map((segment, at) => | |
| 150 | + | segment.className ? ( | |
| 151 | + | <span key={at} className={segment.className}> | |
| 152 | + | {segment.text} | |
| 153 | + | </span> | |
| 154 | + | ) : ( | |
| 155 | + | segment.text | |
| 156 | + | ), | |
| 157 | + | )} | |
| 158 | + | </span> | |
| 159 | + | </div> | |
| 160 | + | ))} | |
| 161 | + | </div> | |
| 162 | + | </div> | |
| 163 | + | ); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // --- Checks --------------------------------------------------------------- | |
| 167 | + | ||
| 168 | + | function seconds(ms: number): string { | |
| 169 | + | if (ms < 1000) return `${ms}ms`; | |
| 170 | + | const s = Math.round(ms / 1000); | |
| 171 | + | return s < 60 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` : `${Math.floor(s / 60)}m ${s % 60}s`; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type Standing = "passed" | "failed" | "running" | "queued" | "skipped"; | |
| 175 | + | ||
| 176 | + | function StandingIcon({ standing, size = 15 }: { standing: Standing; size?: number }) { | |
| 177 | + | if (standing === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" aria-label="Passed" />; | |
| 178 | + | if (standing === "failed") return <CircleX size={size} className="shrink-0 text-danger" aria-label="Failed" />; | |
| 179 | + | if (standing === "running") | |
| 180 | + | return <LoaderCircle size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />; | |
| 181 | + | if (standing === "skipped") return <CircleAlert size={size} className="shrink-0 text-faint" aria-label="Skipped" />; | |
| 182 | + | return <LoaderCircle size={size} className="shrink-0 text-faint" aria-label="Queued" />; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /** The id of the workflow run a status links to, if it links to one. */ | |
| 186 | + | export function runIdOf(status: CommitStatus): string | null { | |
| 187 | + | return /\/actions\/runs\/([^/?#]+)/.exec(status.targetUrl ?? "")?.[1] ?? null; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** Where a job stands, in the box's terms. */ | |
| 191 | + | function jobStanding(job: Job): Standing { | |
| 192 | + | if (job.status !== "completed") return job.status === "in_progress" || job.status === "calling" ? "running" : "queued"; | |
| 193 | + | if (job.conclusion === "success") return "passed"; | |
| 194 | + | if (job.conclusion === "skipped" || job.conclusion === "cancelled") return "skipped"; | |
| 195 | + | return "failed"; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | function statusStanding(status: CommitStatus): Standing { | |
| 199 | + | if (status.state === "pending") return "running"; | |
| 200 | + | return status.state === "success" ? "passed" : "failed"; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /** One row of the checks list: an icon, a name, how long, and a way in. */ | |
| 204 | + | function CheckLine({ | |
| 205 | + | standing, | |
| 206 | + | name, | |
| 207 | + | detail, | |
| 208 | + | time, | |
| 209 | + | to, | |
| 210 | + | }: { | |
| 211 | + | standing: Standing; | |
| 212 | + | name: React.ReactNode; | |
| 213 | + | detail?: React.ReactNode; | |
| 214 | + | time?: string; | |
| 215 | + | to?: string; | |
| 216 | + | }) { | |
| 217 | + | return ( | |
| 218 | + | <div className="flex items-center gap-2.5 px-4 py-2 text-sm"> | |
| 219 | + | <StandingIcon standing={standing} /> | |
| 220 | + | <span className="min-w-0 grow truncate"> | |
| 221 | + | {name} | |
| 222 | + | {detail && <span className="text-muted"> — {detail}</span>} | |
| 223 | + | </span> | |
| 224 | + | {time && <span className="shrink-0 font-mono text-xs text-faint">{time}</span>} | |
| 225 | + | {to && ( | |
| 226 | + | <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline"> | |
| 227 | + | Details | |
| 228 | + | </Link> | |
| 229 | + | )} | |
| 230 | + | </div> | |
| 231 | + | ); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /** An acceptance check: its command, and, open when it failed, what it printed. */ | |
| 235 | + | function AcceptanceCheck({ result }: { result: CheckResult }) { | |
| 236 | + | const timedOut = result.exitCode == null && !result.passed; | |
| 237 | + | return ( | |
| 238 | + | <details className="group" open={!result.passed}> | |
| 239 | + | <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40"> | |
| 240 | + | <StandingIcon standing={result.passed ? "passed" : "failed"} /> | |
| 241 | + | <span className="min-w-0 grow truncate"> | |
| 242 | + | <code className="font-mono text-xs">{result.command}</code> | |
| 243 | + | {!result.passed && ( | |
| 244 | + | <span className="text-muted"> | |
| 245 | + | {" — "} | |
| 246 | + | {timedOut ? "stopped for taking too long" : `exited with code ${result.exitCode}`} | |
| 247 | + | </span> | |
| 248 | + | )} | |
| 249 | + | </span> | |
| 250 | + | <span className="shrink-0 font-mono text-xs text-faint">{seconds(result.durationMs)}</span> | |
| 251 | + | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" /> | |
| 252 | + | </summary> | |
| 253 | + | <div className="mx-4 mb-3 overflow-hidden rounded-lg border border-line bg-bg"> | |
| 254 | + | <LogViewer text={result.output} /> | |
| 255 | + | </div> | |
| 256 | + | </details> | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /** | |
| 261 | + | * Every check on a pull request, at a glance and in detail: its issue's | |
| 262 | + | * acceptance checks, run in a clean sandbox, and the workflows run on its | |
| 263 | + | * head, job by job. | |
| 264 | + | */ | |
| 265 | + | export function ChecksSection({ | |
| 266 | + | run, | |
| 267 | + | commands, | |
| 268 | + | statuses, | |
| 269 | + | jobs, | |
| 270 | + | pull, | |
| 271 | + | base, | |
| 272 | + | earlier, | |
| 273 | + | canRerun, | |
| 274 | + | canRerunWorkflows, | |
| 275 | + | error, | |
| 276 | + | }: { | |
| 277 | + | run: CheckRun | null; | |
| 278 | + | /** The issue's acceptance checks, shown before a run has results. */ | |
| 279 | + | commands: string[]; | |
| 280 | + | statuses: CommitStatus[]; | |
| 281 | + | /** Each workflow run's jobs, by run id, where they could be read. */ | |
| 282 | + | jobs: Record<string, Job[]>; | |
| 283 | + | pull: Pull; | |
| 284 | + | /** The repository's path, `/<owner>/<repo>`. */ | |
| 285 | + | base: string; | |
| 286 | + | earlier: CheckRun[]; | |
| 287 | + | canRerun: boolean; | |
| 288 | + | canRerunWorkflows: boolean; | |
| 289 | + | error?: string | null; | |
| 290 | + | }) { | |
| 291 | + | if (!run && statuses.length === 0) return null; | |
| 292 | + | ||
| 293 | + | // Every row, so they can be counted the same way they are shown. | |
| 294 | + | const acceptance: Standing[] = run | |
| 295 | + | ? run.results.length > 0 | |
| 296 | + | ? run.results.map((result) => (result.passed ? "passed" : "failed")) | |
| 297 | + | : run.status === "queued" || run.status === "running" | |
| 298 | + | ? commands.map(() => (run.status === "running" ? "running" : "queued")) | |
| 299 | + | : run.status === "errored" | |
| 300 | + | ? ["failed"] | |
| 301 | + | : [] | |
| 302 | + | : []; | |
| 303 | + | const workflow: Standing[] = statuses.flatMap((status) => { | |
| 304 | + | const id = runIdOf(status); | |
| 305 | + | const theirs = id ? jobs[id] : undefined; | |
| 306 | + | return theirs && theirs.length > 0 ? theirs.map(jobStanding) : [statusStanding(status)]; | |
| 307 | + | }); | |
| 308 | + | const all = [...acceptance, ...workflow]; | |
| 309 | + | const failed = all.filter((standing) => standing === "failed").length; | |
| 310 | + | const pending = all.filter((standing) => standing === "running" || standing === "queued").length; | |
| 311 | + | const passed = all.filter((standing) => standing === "passed").length; | |
| 312 | + | const total = all.length; | |
| 313 | + | ||
| 314 | + | const headline = | |
| 315 | + | failed > 0 | |
| 316 | + | ? `${failed} of ${total} ${total === 1 ? "check" : "checks"} failed` | |
| 317 | + | : pending > 0 | |
| 318 | + | ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running` | |
| 319 | + | : total > 0 | |
| 320 | + | ? "All checks have passed" | |
| 321 | + | : "Checks"; | |
| 322 | + | const sub = [ | |
| 323 | + | failed > 0 && pending > 0 && `${pending} still running`, | |
| 324 | + | passed > 0 && `${passed} passed`, | |
| 325 | + | all.filter((standing) => standing === "skipped").length > 0 && | |
| 326 | + | `${all.filter((standing) => standing === "skipped").length} skipped or cancelled`, | |
| 327 | + | ] | |
| 328 | + | .filter(Boolean) | |
| 329 | + | .join(", "); | |
| 330 | + | ||
| 331 | + | const pending_ = run?.status === "queued" || run?.status === "running"; | |
| 332 | + | const commitPath = (sha: string) => | |
| 333 | + | pull.fork ? `/${pull.fork.namespace}/${pull.fork.name}/commit/${sha}` : `${base}/commit/${sha}`; | |
| 334 | + | const older = run != null && pull.headCommit != null && run.headCommit !== pull.headCommit; | |
| 335 | + | const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status)); | |
| 336 | + | ||
| 337 | + | return ( | |
| 338 | + | <div> | |
| 339 | + | <div className="flex items-start gap-3 px-4 py-3"> | |
| 340 | + | <span className="mt-0.5 shrink-0"> | |
| 341 | + | {failed > 0 ? ( | |
| 342 | + | <CircleX size={16} className="text-danger" /> | |
| 343 | + | ) : pending > 0 ? ( | |
| 344 | + | <LoaderCircle size={16} className="animate-spin text-warn" /> | |
| 345 | + | ) : ( | |
| 346 | + | <CircleCheck size={16} className="text-accent" /> | |
| 347 | + | )} | |
| 348 | + | </span> | |
| 349 | + | <div className="min-w-0 grow"> | |
| 350 | + | <p className="text-sm font-medium">{headline}</p> | |
| 351 | + | {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>} | |
| 352 | + | </div> | |
| 353 | + | <div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> | |
| 354 | + | {canRerun && run && !pending_ && ( | |
| 355 | + | <Form method="post"> | |
| 356 | + | <input type="hidden" name="action" value="recheck" /> | |
| 357 | + | <Button variant="quiet" type="submit"> | |
| 358 | + | <RotateCw size={13} /> | |
| 359 | + | Re-run checks | |
| 360 | + | </Button> | |
| 361 | + | </Form> | |
| 362 | + | )} | |
| 363 | + | {canRerunWorkflows && | |
| 364 | + | failedRuns.map((status) => ( | |
| 365 | + | <Form method="post" key={status.context}> | |
| 366 | + | <input type="hidden" name="action" value="rerun-workflow" /> | |
| 367 | + | <input type="hidden" name="run" value={runIdOf(status) ?? ""} /> | |
| 368 | + | <Button variant="quiet" type="submit" title={`Re-run the failed jobs of ${status.context}`}> | |
| 369 | + | <RotateCw size={13} /> | |
| 370 | + | Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""} | |
| 371 | + | </Button> | |
| 372 | + | </Form> | |
| 373 | + | ))} | |
| 374 | + | </div> | |
| 375 | + | </div> | |
| 376 | + | {error && ( | |
| 377 | + | <div className="px-4 pb-2"> | |
| 378 | + | <ErrorText>{error}</ErrorText> | |
| 379 | + | </div> | |
| 380 | + | )} | |
| 381 | + | ||
| 382 | + | <div className="divide-y divide-line border-t border-line"> | |
| 383 | + | {run && ( | |
| 384 | + | <section aria-label="Acceptance checks" className="py-1"> | |
| 385 | + | <p className="flex flex-wrap items-center gap-x-1.5 px-4 pt-1.5 pb-1 text-xs text-faint"> | |
| 386 | + | <span className="font-medium text-muted">Acceptance checks</span> | |
| 387 | + | <span>· in a clean sandbox, on</span> | |
| 388 | + | <Link to={commitPath(run.headCommit)} className="inline-flex items-center gap-1 font-mono hover:text-fg"> | |
| 389 | + | <GitCommitHorizontal size={12} /> | |
| 390 | + | {run.headCommit.slice(0, 7)} | |
| 391 | + | </Link> | |
| 392 | + | {run.finishedAt ? ( | |
| 393 | + | <span> | |
| 394 | + | · finished <TimeAgo at={run.finishedAt} /> | |
| 395 | + | </span> | |
| 396 | + | ) : ( | |
| 397 | + | <span> | |
| 398 | + | · started <TimeAgo at={run.createdAt} /> | |
| 399 | + | </span> | |
| 400 | + | )} | |
| 401 | + | </p> | |
| 402 | + | {older && ( | |
| 403 | + | <p className="mx-4 my-1.5 flex items-start gap-2 rounded-md bg-warn/10 px-2.5 py-1.5 text-xs text-warn"> | |
| 404 | + | <TriangleAlert size={13} className="mt-0.5 shrink-0" /> | |
| 405 | + | These checks ran on an older commit. The latest push,{" "} | |
| 406 | + | <span className="font-mono">{pull.headCommit?.slice(0, 7)}</span>, has not been checked yet. | |
| 407 | + | </p> | |
| 408 | + | )} | |
| 409 | + | {run.status === "errored" && ( | |
| 410 | + | <div className="mx-4 my-1.5 rounded-lg border border-danger/40 bg-danger/5 px-3 py-2 text-sm"> | |
| 411 | + | <p className="flex items-center gap-2 font-medium text-danger"> | |
| 412 | + | <TriangleAlert size={14} /> | |
| 413 | + | The checks could not be run | |
| 414 | + | </p> | |
| 415 | + | {run.error && <p className="mt-1 text-muted">{run.error}</p>} | |
| 416 | + | </div> | |
| 417 | + | )} | |
| 418 | + | {run.status === "failed" && run.error && ( | |
| 419 | + | <p className="mx-4 my-1.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm text-muted">{run.error}</p> | |
| 420 | + | )} | |
| 421 | + | {run.results.length > 0 | |
| 422 | + | ? run.results.map((result, index) => <AcceptanceCheck key={`${result.command}-${index}`} result={result} />) | |
| 423 | + | : pending_ && | |
| 424 | + | commands.map((command) => ( | |
| 425 | + | <CheckLine | |
| 426 | + | key={command} | |
| 427 | + | standing={run.status === "running" ? "running" : "queued"} | |
| 428 | + | name={<code className="font-mono text-xs">{command}</code>} | |
| 429 | + | detail={run.status === "running" ? "running" : "waiting for a sandbox"} | |
| 430 | + | /> | |
| 431 | + | ))} | |
| 432 | + | </section> | |
| 433 | + | )} | |
| 434 | + | ||
| 435 | + | {statuses.length > 0 && ( | |
| 436 | + | <section aria-label="Workflows" className="py-1"> | |
| 437 | + | <p className="px-4 pt-1.5 pb-1 text-xs font-medium text-muted">Workflows</p> | |
| 438 | + | {statuses.map((status) => { | |
| 439 | + | const id = runIdOf(status); | |
| 440 | + | const runPath = id ? `${base}/actions/runs/${id}` : undefined; | |
| 441 | + | const theirs = id ? jobs[id] : undefined; | |
| 442 | + | if (!theirs || theirs.length === 0) { | |
| 443 | + | return ( | |
| 444 | + | <CheckLine | |
| 445 | + | key={status.context} | |
| 446 | + | standing={statusStanding(status)} | |
| 447 | + | name={status.context} | |
| 448 | + | detail={status.description ?? undefined} | |
| 449 | + | to={runPath} | |
| 450 | + | /> | |
| 451 | + | ); | |
| 452 | + | } | |
| 453 | + | return theirs.map((job) => ( | |
| 454 | + | <CheckLine | |
| 455 | + | key={`${status.context}-${job.id}`} | |
| 456 | + | standing={jobStanding(job)} | |
| 457 | + | name={ | |
| 458 | + | <> | |
| 459 | + | <span className="text-muted">{status.context} / </span> | |
| 460 | + | {job.name} | |
| 461 | + | </> | |
| 462 | + | } | |
| 463 | + | detail={job.reason ?? (jobStanding(job) === "failed" ? "failed" : undefined)} | |
| 464 | + | time={duration(job.startedAt, job.finishedAt) || undefined} | |
| 465 | + | to={`${runPath}?job=${job.id}`} | |
| 466 | + | /> | |
| 467 | + | )); | |
| 468 | + | })} | |
| 469 | + | </section> | |
| 470 | + | )} | |
| 471 | + | ||
| 472 | + | {earlier.length > 0 && ( | |
| 473 | + | <details className="group"> | |
| 474 | + | <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2 text-xs text-muted hover:text-fg"> | |
| 475 | + | <History size={13} /> | |
| 476 | + | Earlier runs of the acceptance checks ({earlier.length}) | |
| 477 | + | <ChevronRight size={13} className="transition-transform group-open:rotate-90" /> | |
| 478 | + | </summary> | |
| 479 | + | <ul className="space-y-1 px-4 pb-3 text-xs"> | |
| 480 | + | {earlier.map((before) => ( | |
| 481 | + | <li key={before.id} className="flex items-center gap-2 text-muted"> | |
| 482 | + | <StandingIcon | |
| 483 | + | size={13} | |
| 484 | + | standing={ | |
| 485 | + | before.status === "passed" | |
| 486 | + | ? "passed" | |
| 487 | + | : before.status === "queued" || before.status === "running" | |
| 488 | + | ? "queued" | |
| 489 | + | : "failed" | |
| 490 | + | } | |
| 491 | + | /> | |
| 492 | + | <Link to={commitPath(before.headCommit)} className="font-mono hover:text-fg"> | |
| 493 | + | {before.headCommit.slice(0, 7)} | |
| 494 | + | </Link> | |
| 495 | + | <span className="min-w-0 truncate"> | |
| 496 | + | {before.status === "errored" | |
| 497 | + | ? `could not run${before.error ? `: ${before.error}` : ""}` | |
| 498 | + | : before.results.length > 0 | |
| 499 | + | ? `${before.results.filter((result) => result.passed).length} of ${before.results.length} passed` | |
| 500 | + | : before.status} | |
| 501 | + | </span> | |
| 502 | + | <span className="ml-auto shrink-0 text-faint"> | |
| 503 | + | <TimeAgo at={before.createdAt} /> | |
| 504 | + | </span> | |
| 505 | + | </li> | |
| 506 | + | ))} | |
| 507 | + | </ul> | |
| 508 | + | </details> | |
| 509 | + | )} | |
| 510 | + | </div> | |
| 511 | + | </div> | |
| 512 | + | ); | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // --- Mergeability --------------------------------------------------------- | |
| 516 | + | ||
| 517 | + | /** How to resolve the conflicts by hand, step by step, for a branch or a fork. */ | |
| 518 | + | export function commandLineSteps(pull: Pull, owner: string, repo: string, defaultBranch: string, files: string[]): string[] { | |
| 519 | + | const upstream = `https://g1t.sh/${owner}/${repo}.git`; | |
| 520 | + | const add = files.length > 0 && files.length <= 6 ? `git add ${files.join(" ")}` : "git add -A"; | |
| 521 | + | if (pull.branch) { | |
| 522 | + | return [ | |
| 523 | + | `git fetch origin`, | |
| 524 | + | `git checkout ${pull.branch}`, | |
| 525 | + | `git merge origin/${defaultBranch}`, | |
| 526 | + | `${add} && git commit --no-edit`, | |
| 527 | + | `git push origin ${pull.branch}`, | |
| 528 | + | ]; | |
| 529 | + | } | |
| 530 | + | const fork = pull.fork!; | |
| 531 | + | return [ | |
| 532 | + | `git clone https://g1t.sh/${fork.namespace}/${fork.name}.git && cd ${fork.name}`, | |
| 533 | + | `git pull --no-rebase ${upstream} ${defaultBranch}`, | |
| 534 | + | `${add} && git commit --no-edit`, | |
| 535 | + | `git push`, | |
| 536 | + | ]; | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | /** | |
| 540 | + | * The branch conflicts with its target: which files, and three ways out — | |
| 541 | + | * the g1t agent, the browser (soon), or the command line. | |
| 542 | + | */ | |
| 543 | + | export function ConflictsSection({ | |
| 544 | + | conflicts, | |
| 545 | + | pull, | |
| 546 | + | owner, | |
| 547 | + | repo, | |
| 548 | + | defaultBranch, | |
| 549 | + | changesUrl, | |
| 550 | + | canResolve, | |
| 551 | + | resolving, | |
| 552 | + | onResolve, | |
| 553 | + | error, | |
| 554 | + | }: { | |
| 555 | + | conflicts: string[]; | |
| 556 | + | pull: Pull; | |
| 557 | + | owner: string; | |
| 558 | + | repo: string; | |
| 559 | + | defaultBranch: string; | |
| 560 | + | /** Where the pull request's changes are shown, to link each file. */ | |
| 561 | + | changesUrl: string; | |
| 562 | + | /** Whether the viewer may have the g1t agent resolve them. */ | |
| 563 | + | canResolve: boolean; | |
| 564 | + | resolving: boolean; | |
| 565 | + | onResolve: () => void; | |
| 566 | + | error?: string | null; | |
| 567 | + | }) { | |
| 568 | + | const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts); | |
| 569 | + | return ( | |
| 570 | + | <div className="bg-danger/5"> | |
| 571 | + | <div className="flex gap-3 px-4 py-3"> | |
| 572 | + | <CircleAlert size={16} className="mt-0.5 shrink-0 text-danger" /> | |
| 573 | + | <div className="min-w-0 grow"> | |
| 574 | + | <p className="text-sm font-medium">This branch has conflicts that must be resolved</p> | |
| 575 | + | <p className="mt-0.5 text-sm text-muted"> | |
| 576 | + | Merging <span className="font-mono">{defaultBranch}</span> into it stops on{" "} | |
| 577 | + | {conflicts.length === 1 ? "one file" : `${conflicts.length} files`}. It cannot merge until they are resolved. | |
| 578 | + | </p> | |
| 579 | + | {conflicts.length > 0 && ( | |
| 580 | + | <> | |
| 581 | + | <p className="mt-3 text-xs font-medium text-muted">Conflicting files</p> | |
| 582 | + | <ul className="mt-1.5 space-y-1"> | |
| 583 | + | {conflicts.map((path) => ( | |
| 584 | + | <li key={path}> | |
| 585 | + | <Link | |
| 586 | + | to={`${changesUrl}#file-${path}`} | |
| 587 | + | className="font-mono text-xs text-fg hover:text-danger hover:underline" | |
| 588 | + | > | |
| 589 | + | {path} | |
| 590 | + | </Link> | |
| 591 | + | </li> | |
| 592 | + | ))} | |
| 593 | + | </ul> | |
| 594 | + | </> | |
| 595 | + | )} | |
| 596 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 597 | + | {canResolve && ( | |
| 598 | + | <Form method="post" onSubmit={onResolve}> | |
| 599 | + | <input type="hidden" name="action" value="update" /> | |
| 600 | + | <Button variant="primary" type="submit" disabled={resolving}> | |
| 601 | + | <Sparkles size={14} /> | |
| 602 | + | {resolving ? "The g1t agent is resolving them…" : "Resolve with g1t agent"} | |
| 603 | + | </Button> | |
| 604 | + | </Form> | |
| 605 | + | )} | |
| 606 | + | <Tooltip> | |
| 607 | + | <TooltipTrigger asChild> | |
| 608 | + | {/* A disabled button gets no pointer events; the span carries the tooltip. */} | |
| 609 | + | <span tabIndex={0} className="inline-flex"> | |
| 610 | + | <Button variant="quiet" type="button" disabled aria-disabled="true"> | |
| 611 | + | Resolve in the browser | |
| 612 | + | <span className="rounded-full border border-line px-1.5 text-[0.625rem] text-faint">Soon</span> | |
| 613 | + | </Button> | |
| 614 | + | </span> | |
| 615 | + | </TooltipTrigger> | |
| 616 | + | <TooltipContent> | |
| 617 | + | An editor for conflicts in the browser is coming. Until then, have the g1t agent resolve them, or use the | |
| 618 | + | command line. | |
| 619 | + | </TooltipContent> | |
| 620 | + | </Tooltip> | |
| 621 | + | </div> | |
| 622 | + | {error && ( | |
| 623 | + | <div className="mt-2"> | |
| 624 | + | <ErrorText>{error}</ErrorText> | |
| 625 | + | </div> | |
| 626 | + | )} | |
| 627 | + | <details className="group mt-3"> | |
| 628 | + | <summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted hover:text-fg"> | |
| 629 | + | <Terminal size={13} /> | |
| 630 | + | Resolve on the command line | |
| 631 | + | <ChevronRight size={13} className="transition-transform group-open:rotate-90" /> | |
| 632 | + | </summary> | |
| 633 | + | <ol className="mt-2 space-y-2 text-xs text-muted"> | |
| 634 | + | {steps.map((step, index) => ( | |
| 635 | + | <li key={step}> | |
| 636 | + | <p className="mb-1"> | |
| 637 | + | {index + 1}.{" "} | |
| 638 | + | {step.startsWith("git add") | |
| 639 | + | ? "Fix the conflicts in each file, removing the conflict markers, then commit the merge:" | |
| 640 | + | : step.startsWith("git push") | |
| 641 | + | ? "Push it. The pull request updates, and its checks run again:" | |
| 642 | + | : step.includes("merge") || step.includes("pull") | |
| 643 | + | ? `Merge ${defaultBranch} in. Git stops on the conflicting files:` | |
| 644 | + | : step.startsWith("git clone") | |
| 645 | + | ? "Clone the pull request's fork:" | |
| 646 | + | : step.startsWith("git checkout") | |
| 647 | + | ? "Check out the pull request's branch:" | |
| 648 | + | : "Get the latest of both:"} | |
| 649 | + | </p> | |
| 650 | + | <CopyLine text={step} prompt /> | |
| 651 | + | </li> | |
| 652 | + | ))} | |
| 653 | + | </ol> | |
| 654 | + | {pull.fork && ( | |
| 655 | + | <p className="mt-2 text-xs text-faint"> | |
| 656 | + | A pull request's fork takes pushes from whoever opened it. | |
| 657 | + | </p> | |
| 658 | + | )} | |
| 659 | + | </details> | |
| 660 | + | </div> | |
| 661 | + | </div> | |
| 662 | + | </div> | |
| 663 | + | ); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | /** Whether the change merges cleanly, when that is not a conflict. */ | |
| 667 | + | export function MergeabilityRow({ | |
| 668 | + | mergeable, | |
| 669 | + | defaultBranch, | |
| 670 | + | }: { | |
| 671 | + | mergeable: Mergeable; | |
| 672 | + | defaultBranch: string; | |
| 673 | + | }) { | |
| 674 | + | if (mergeable === "checking") { | |
| 675 | + | return ( | |
| 676 | + | <div className="flex gap-3 px-4 py-3 text-sm"> | |
| 677 | + | <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-faint" /> | |
| 678 | + | <div> | |
| 679 | + | <p className="font-medium">Checking whether this merges cleanly…</p> | |
| 680 | + | <p className="mt-0.5 text-muted"> | |
| 681 | + | It and <span className="font-mono">{defaultBranch}</span> changed some of the same files, so g1t is merging | |
| 682 | + | the two in a sandbox to see. | |
| 683 | + | </p> | |
| 684 | + | </div> | |
| 685 | + | </div> | |
| 686 | + | ); | |
| 687 | + | } | |
| 688 | + | if (mergeable === "clean") { | |
| 689 | + | return ( | |
| 690 | + | <div className="flex gap-3 px-4 py-3 text-sm"> | |
| 691 | + | <CircleCheck size={16} className="mt-0.5 shrink-0 text-accent" /> | |
| 692 | + | <p className="font-medium"> | |
| 693 | + | This branch has no conflicts with <span className="font-mono">{defaultBranch}</span> | |
| 694 | + | </p> | |
| 695 | + | </div> | |
| 696 | + | ); | |
| 697 | + | } | |
| 698 | + | return null; | |
| 699 | + | } |
| 1 | + | import { Check } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { Button } from "./ui"; | |
| 8 | + | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | + | import { Input } from "./ui/input"; | |
| 10 | + | import { Textarea } from "./ui/textarea"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The Profile section of a person's settings: what everyone sees at | |
| 14 | + | * `/u/<username>`. Posts `intent=profile`; identity checks every field. | |
| 15 | + | */ | |
| 16 | + | export function ProfileSection({ | |
| 17 | + | username, | |
| 18 | + | profile, | |
| 19 | + | error, | |
| 20 | + | saved, | |
| 21 | + | }: { | |
| 22 | + | username: string; | |
| 23 | + | profile: Profile | null; | |
| 24 | + | error?: string; | |
| 25 | + | saved: boolean; | |
| 26 | + | }) { | |
| 27 | + | const navigation = useNavigation(); | |
| 28 | + | const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile"; | |
| 29 | + | const [bio, setBio] = useState(profile?.bio ?? ""); | |
| 30 | + | return ( | |
| 31 | + | <section id="profile" className="scroll-mt-20"> | |
| 32 | + | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 33 | + | <h2 className="font-medium">Profile</h2> | |
| 34 | + | <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 35 | + | View your profile | |
| 36 | + | </Link> | |
| 37 | + | </div> | |
| 38 | + | <p className="mt-1 text-sm text-muted"> | |
| 39 | + | Shown to everyone at <span className="font-mono text-fg">g1t.sh/u/{username}</span>. All of it is | |
| 40 | + | optional; your email address is never shown. | |
| 41 | + | </p> | |
| 42 | + | <Form method="post" className="mt-4 grid gap-4 sm:grid-cols-2"> | |
| 43 | + | <input type="hidden" name="intent" value="profile" /> | |
| 44 | + | <Field> | |
| 45 | + | <FieldLabel htmlFor="profile-name">Name</FieldLabel> | |
| 46 | + | <Input | |
| 47 | + | id="profile-name" | |
| 48 | + | name="name" | |
| 49 | + | defaultValue={profile?.name ?? ""} | |
| 50 | + | maxLength={PROFILE_LIMITS.name} | |
| 51 | + | placeholder={username} | |
| 52 | + | /> | |
| 53 | + | </Field> | |
| 54 | + | <Field> | |
| 55 | + | <FieldLabel htmlFor="profile-pronouns">Pronouns</FieldLabel> | |
| 56 | + | <Input | |
| 57 | + | id="profile-pronouns" | |
| 58 | + | name="pronouns" | |
| 59 | + | defaultValue={profile?.pronouns ?? ""} | |
| 60 | + | maxLength={PROFILE_LIMITS.pronouns} | |
| 61 | + | placeholder="they/them" | |
| 62 | + | /> | |
| 63 | + | </Field> | |
| 64 | + | <Field className="sm:col-span-2"> | |
| 65 | + | <FieldLabel htmlFor="profile-bio">Bio</FieldLabel> | |
| 66 | + | <Textarea | |
| 67 | + | id="profile-bio" | |
| 68 | + | name="bio" | |
| 69 | + | rows={2} | |
| 70 | + | value={bio} | |
| 71 | + | onChange={(event) => setBio(event.currentTarget.value)} | |
| 72 | + | maxLength={PROFILE_LIMITS.bio} | |
| 73 | + | placeholder="What you work on, in a line or two." | |
| 74 | + | /> | |
| 75 | + | <FieldDescription> | |
| 76 | + | {bio.length}/{PROFILE_LIMITS.bio} characters. Also what a link to your profile says. | |
| 77 | + | </FieldDescription> | |
| 78 | + | </Field> | |
| 79 | + | <Field> | |
| 80 | + | <FieldLabel htmlFor="profile-location">Location</FieldLabel> | |
| 81 | + | <Input | |
| 82 | + | id="profile-location" | |
| 83 | + | name="location" | |
| 84 | + | defaultValue={profile?.location ?? ""} | |
| 85 | + | maxLength={PROFILE_LIMITS.location} | |
| 86 | + | placeholder="Portland, Oregon" | |
| 87 | + | /> | |
| 88 | + | </Field> | |
| 89 | + | <Field> | |
| 90 | + | <FieldLabel htmlFor="profile-website">Website</FieldLabel> | |
| 91 | + | <Input | |
| 92 | + | id="profile-website" | |
| 93 | + | name="website" | |
| 94 | + | inputMode="url" | |
| 95 | + | defaultValue={profile?.website ?? ""} | |
| 96 | + | maxLength={PROFILE_LIMITS.website} | |
| 97 | + | placeholder="https://example.com" | |
| 98 | + | /> | |
| 99 | + | <FieldDescription>An https:// address.</FieldDescription> | |
| 100 | + | </Field> | |
| 101 | + | <div className="flex flex-wrap items-center gap-3 sm:col-span-2"> | |
| 102 | + | <Button type="submit" disabled={busy}> | |
| 103 | + | {busy ? "Saving…" : "Save profile"} | |
| 104 | + | </Button> | |
| 105 | + | {saved && !busy && !error && ( | |
| 106 | + | <span className="inline-flex items-center gap-1.5 text-sm text-muted"> | |
| 107 | + | <Check size={14} className="text-accent" /> | |
| 108 | + | Saved | |
| 109 | + | </span> | |
| 110 | + | )} | |
| 111 | + | <FieldError>{error}</FieldError> | |
| 112 | + | </div> | |
| 113 | + | </Form> | |
| 114 | + | </section> | |
| 115 | + | ); | |
| 116 | + | } |
| 1 | − | import { GitBranch, Lock, Network, Rocket, Settings, Webhook } from "lucide-react"; | |
| 1 | + | import { GitBranch, Globe, Lock, Network, Rocket, Settings, Webhook } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | 3 | import { TabLink } from "./ui"; | |
| 4 | 4 | ||
| 15 | 15 | <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 16 | 16 | Deployments | |
| 17 | 17 | </TabLink> | |
| 18 | + | <TabLink to={`${base}/settings/domains`} icon={<Globe size={15} />}> | |
| 19 | + | Domains | |
| 20 | + | </TabLink> | |
| 18 | 21 | <TabLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}> | |
| 19 | 22 | Dependencies | |
| 20 | 23 | </TabLink> |
| 35 | 35 | Webhook, | |
| 36 | 36 | PlayCircle, | |
| 37 | 37 | Bot, | |
| 38 | + | Brain, | |
| 38 | 39 | ShieldCheck, | |
| 39 | 40 | Rocket, | |
| 40 | 41 | X, | |
| 486 | 487 | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 487 | 488 | Pull requests | |
| 488 | 489 | </SidebarLink> | |
| 489 | − | <SidebarSoonLink to={`${base}/soon/agents`} also={soonPaths(base, "Agents")} icon={<Bot size={15} />} about="Every agent at work on this project, its sessions, playbooks and memory."> | |
| 490 | + | <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}> | |
| 490 | 491 | Agents | |
| 491 | − | </SidebarSoonLink> | |
| 492 | + | </SidebarLink> | |
| 492 | 493 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 493 | 494 | Workflows | |
| 494 | 495 | </SidebarLink> | |
| 564 | 565 | // In a repository, or on the way into one, its own menu takes the sidebar. | |
| 565 | 566 | const target = going ?? pathname; | |
| 566 | 567 | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); | |
| 567 | − | const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 568 | + | const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 568 | 569 | const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-"; | |
| 569 | 570 | const away = inSettings || inAccount || inRepo; | |
| 570 | 571 | // The repository the menu is for: the one loaded if it is the one being | |
| 675 | 676 | </SidebarGroup> | |
| 676 | 677 | {ws && ( | |
| 677 | 678 | <SidebarGroup title="Across projects"> | |
| 679 | + | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 680 | + | Agent fleet | |
| 681 | + | </SidebarLink> | |
| 682 | + | <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}> | |
| 683 | + | Memory | |
| 684 | + | </SidebarLink> | |
| 678 | 685 | {roadmapIn("Workspace").map((item) => ( | |
| 679 | 686 | <SidebarSoonLink | |
| 680 | 687 | key={item.key} |
| 197 | 197 | request_changes: { label: "requested changes", style: "text-danger" }, | |
| 198 | 198 | } as const; | |
| 199 | 199 | ||
| 200 | + | /** How g1t's own agents are named; they have no profile to link to. */ | |
| 201 | + | const AGENTS = new Set(["g1t-agent", "g1t agent", "g1t"]); | |
| 202 | + | ||
| 203 | + | /** | |
| 204 | + | * A person's name (or `children`) linking to their profile at `/u/<name>`. | |
| 205 | + | * g1t's agents, and names that are not usernames, stay plain text. | |
| 206 | + | */ | |
| 207 | + | export function PersonLink({ | |
| 208 | + | name, | |
| 209 | + | className, | |
| 210 | + | label, | |
| 211 | + | children, | |
| 212 | + | }: { | |
| 213 | + | name: string; | |
| 214 | + | className?: string; | |
| 215 | + | label?: string; | |
| 216 | + | children?: ReactNode; | |
| 217 | + | }) { | |
| 218 | + | if (AGENTS.has(name) || !/^[a-z0-9-]{1,39}$/i.test(name)) { | |
| 219 | + | return <span className={className}>{children ?? name}</span>; | |
| 220 | + | } | |
| 221 | + | return ( | |
| 222 | + | <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}> | |
| 223 | + | {children ?? name} | |
| 224 | + | </Link> | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | ||
| 200 | 228 | /** | |
| 201 | 229 | * Comments in order, then the box to add one. On a pull request, `review` | |
| 202 | 230 | * says where its changes are shown and whether the viewer may give a | |
| 224 | 252 | return ( | |
| 225 | 253 | <div className="flex gap-3"> | |
| 226 | 254 | <span className="mt-1 hidden shrink-0 sm:block"> | |
| 227 | − | <Avatar name={author} size={32} /> | |
| 255 | + | <PersonLink name={author} label={`${author}'s profile`}> | |
| 256 | + | <Avatar name={author} size={32} /> | |
| 257 | + | </PersonLink> | |
| 228 | 258 | </span> | |
| 229 | 259 | <article className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface"> | |
| 230 | 260 | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted"> | |
| 231 | 261 | <span className="sm:hidden"> | |
| 232 | 262 | <Avatar name={author} size={18} /> | |
| 233 | 263 | </span> | |
| 234 | − | <span className="font-medium text-fg">{author}</span> | |
| 264 | + | <PersonLink name={author} className="font-medium text-fg hover:underline" /> | |
| 235 | 265 | {action} | |
| 236 | 266 | {at && <TimeAgo at={at} />} | |
| 237 | 267 | {aside && <span className="ml-auto min-w-0">{aside}</span>} | |
| 244 | 274 | ||
| 245 | 275 | type Review = { changesUrl: string; canJudge: boolean }; | |
| 246 | 276 | ||
| 247 | − | /** Text with each `#12` linked to the issue or pull request of that number. */ | |
| 248 | − | function WithReferences({ text, base }: { text: string; base?: string }) { | |
| 277 | + | /** | |
| 278 | + | * Text with each `#12` linked to the issue or pull request of that number, | |
| 279 | + | * and each file named in backticks shown as code, linked to its diff when | |
| 280 | + | * `filesUrl` says where the changes are. | |
| 281 | + | */ | |
| 282 | + | function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) { | |
| 249 | 283 | if (!base) return <>{text}</>; | |
| 250 | 284 | return ( | |
| 251 | 285 | <> | |
| 252 | − | {text.split(/(#\d+)/).map((part, index) => | |
| 253 | − | /^#\d+$/.test(part) ? ( | |
| 286 | + | {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) => | |
| 287 | + | /^`[^`]+`$/.test(part) ? ( | |
| 288 | + | filesUrl ? ( | |
| 289 | + | <Link | |
| 290 | + | key={index} | |
| 291 | + | to={`${filesUrl}#file-${part.slice(1, -1)}`} | |
| 292 | + | className="font-mono text-xs text-fg hover:underline" | |
| 293 | + | > | |
| 294 | + | {part.slice(1, -1)} | |
| 295 | + | </Link> | |
| 296 | + | ) : ( | |
| 297 | + | <code key={index} className="font-mono text-xs text-fg"> | |
| 298 | + | {part.slice(1, -1)} | |
| 299 | + | </code> | |
| 300 | + | ) | |
| 301 | + | ) : /^#\d+$/.test(part) ? ( | |
| 254 | 302 | // Issues and pull requests share numbers; the issue page forwards. | |
| 255 | 303 | <Link | |
| 256 | 304 | key={index} | |
| 268 | 316 | } | |
| 269 | 317 | ||
| 270 | 318 | /** Something that happened, as one line on the conversation's rail. */ | |
| 271 | − | function TimelineEvent({ comment, base }: { comment: Comment; base?: string }) { | |
| 319 | + | function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) { | |
| 272 | 320 | return ( | |
| 273 | 321 | <div className="flex items-center gap-3 text-sm text-muted"> | |
| 274 | 322 | <span className="hidden w-8 shrink-0 justify-center sm:flex"> | |
| 276 | 324 | </span> | |
| 277 | 325 | <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5"> | |
| 278 | 326 | <Avatar name={comment.author.username} size={16} /> | |
| 279 | − | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 327 | + | <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" /> | |
| 280 | 328 | <span> | |
| 281 | − | <WithReferences text={comment.body} base={base} /> | |
| 329 | + | <WithReferences text={comment.body} base={base} filesUrl={filesUrl} /> | |
| 282 | 330 | </span> | |
| 283 | 331 | <span className="text-faint"> | |
| 284 | 332 | · <TimeAgo at={comment.createdAt} /> | |
| 306 | 354 | <> | |
| 307 | 355 | {comments.map((comment) => { | |
| 308 | 356 | if (comment.kind === "event") { | |
| 309 | − | return <TimelineEvent key={comment.id} comment={comment} base={base} />; | |
| 357 | + | return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />; | |
| 310 | 358 | } | |
| 311 | 359 | const verdict = comment.verdict && VERDICTS[comment.verdict]; | |
| 312 | 360 | return ( |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { isValidNamespace } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { identity } from "./services.server"; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * A workspace that was renamed keeps its old address as a redirect for | |
| 9 | + | * `SLUG_HOLD_DAYS`. Call this where a page is about to 404 on its first | |
| 10 | + | * path segment: if `slug` is an old name, it throws a 301 to the same | |
| 11 | + | * address (path and query) under the current one. Otherwise it returns, and | |
| 12 | + | * the caller 404s as before. Only the not-found path pays for the lookup. | |
| 13 | + | */ | |
| 14 | + | export async function redirectIfRenamed(request: Request, slug: string): Promise<void> { | |
| 15 | + | const old = slug.toLowerCase(); | |
| 16 | + | // Nothing that could never have been a workspace's name is looked up. | |
| 17 | + | if (!isValidNamespace(old)) return; | |
| 18 | + | let current: string | null = null; | |
| 19 | + | try { | |
| 20 | + | current = await identity.resolveSlug(old); | |
| 21 | + | } catch { | |
| 22 | + | // A lookup that fails leaves the page a 404, never a 500. | |
| 23 | + | return; | |
| 24 | + | } | |
| 25 | + | if (!current || current === old) return; | |
| 26 | + | const url = new URL(request.url); | |
| 27 | + | const segments = url.pathname.split("/"); | |
| 28 | + | // segments[0] is the empty string before the leading slash. | |
| 29 | + | segments[1] = current; | |
| 30 | + | throw redirect(segments.join("/") + url.search, 301); | |
| 31 | + | } |
| 120 | 120 | }, | |
| 121 | 121 | ||
| 122 | 122 | // --- Agents ------------------------------------------------------------- | |
| 123 | − | { | |
| 124 | − | key: "agents", | |
| 125 | − | title: "At work", | |
| 126 | − | section: "Agents", | |
| 127 | − | summary: "Every agent at work on this project now, and a way to steer it.", | |
| 128 | − | why: "Agents are the project's busiest contributors. See each one live: what issue it holds, what it is doing this minute, what it has spent, and send it a word mid-run.", | |
| 129 | − | plans: [ | |
| 130 | − | "Live view of each running agent, with its current step", | |
| 131 | − | "Steer, pause or stop a run, and hand it to a person", | |
| 132 | − | "What each run has cost so far", | |
| 133 | − | "Your own agents and g1t's side by side", | |
| 134 | − | ], | |
| 135 | − | today: { label: "Pull requests", path: "pulls" }, | |
| 136 | − | }, | |
| 137 | − | { | |
| 138 | − | key: "sessions", | |
| 139 | − | title: "Sessions", | |
| 140 | − | section: "Agents", | |
| 141 | − | summary: "Every agent session that changed this project, searchable.", | |
| 142 | − | why: "When an agent writes a line, its reasoning is worth keeping. Every session is kept, searchable, and linked from the lines it wrote, so why-blame answers why the code is the way it is.", | |
| 143 | − | plans: [ | |
| 144 | − | "Every session, with its prompt, steps, tools and result", | |
| 145 | − | "Search by file, issue, agent or words", | |
| 146 | − | "Why-blame: from any line to the session that wrote it", | |
| 147 | − | "Sessions as context for the next agent on the same code", | |
| 148 | − | ], | |
| 149 | − | today: { label: "Commits", path: "commits" }, | |
| 150 | − | }, | |
| 123 | + | // At work, Sessions and Memory are built (see project-nav.ts), and so is | |
| 124 | + | // the workspace's Agent fleet. | |
| 151 | 125 | { | |
| 152 | 126 | key: "playbooks", | |
| 153 | 127 | title: "Playbooks", | |
| 160 | 134 | "Paths agents may not touch without a person", | |
| 161 | 135 | "Learned from reviews: what people corrected becomes a rule", | |
| 162 | 136 | ], | |
| 163 | − | today: { label: "Settings", path: "settings" }, | |
| 164 | − | }, | |
| 165 | − | { | |
| 166 | − | key: "memory", | |
| 167 | − | title: "Memory", | |
| 168 | − | section: "Agents", | |
| 169 | − | summary: "What agents have learned about this project, kept and curated.", | |
| 170 | − | why: "Agents that forget repeat mistakes. g1t keeps what they learn about the code, its quirks and its people, and lets you see, edit and delete it.", | |
| 171 | − | plans: ["Facts agents learned, with where they learned them", "Edit or remove anything", "Shared across every agent on the project"], | |
| 172 | − | }, | |
| 173 | − | ||
| 174 | − | { | |
| 175 | − | key: "fleet", | |
| 176 | − | title: "Agent fleet", | |
| 177 | − | section: "Workspace", | |
| 178 | − | summary: "Every agent at work across the workspace, and what each is costing.", | |
| 179 | − | why: "Agents work on many projects at once. The fleet shows them all: what each holds, how far along it is, and where people are needed.", | |
| 180 | − | plans: [ | |
| 181 | − | "Every running agent across projects, live", | |
| 182 | − | "Queue of work waiting for an agent, by priority", | |
| 183 | − | "Spend by agent, project and kind of work", | |
| 184 | − | "Pause or redirect agents across the workspace", | |
| 185 | − | ], | |
| 137 | + | today: { label: "Memory", path: "memory" }, | |
| 186 | 138 | }, | |
| 187 | 139 | ||
| 188 | 140 | // --- Deployments ---------------------------------------------------------- |
| 2 | 2 | ||
| 3 | 3 | import { | |
| 4 | 4 | actionsClient, | |
| 5 | + | agentsClient, | |
| 5 | 6 | billingClient, | |
| 6 | 7 | deploymentsClient, | |
| 7 | 8 | projectsClient, | |
| 23 | 24 | export const actions = actionsClient(env.ACTIONS); | |
| 24 | 25 | export const deployments = deploymentsClient(env.DEPLOYMENTS); | |
| 25 | 26 | export const projects = projectsClient(env.PROJECTS); | |
| 27 | + | /** Agent runs, sessions and memory: methods of the work service. */ | |
| 28 | + | export const agents = agentsClient(env.WORK); |
| 12 | 12 | route("oauth/authorize", "routes/oauth-authorize.tsx"), | |
| 13 | 13 | route("new", "routes/new.tsx"), | |
| 14 | 14 | route("settings", "routes/settings.tsx"), | |
| 15 | + | // People, apart from workspaces: `u` is a reserved name. | |
| 16 | + | route("u/:username", "routes/user.tsx"), | |
| 15 | 17 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 16 | 18 | route("pricing", "routes/pricing.tsx"), | |
| 17 | 19 | route("search", "routes/explore.tsx", { id: "search" }), | |
| 27 | 29 | route("-/webhooks", "routes/workspace/webhooks.tsx"), | |
| 28 | 30 | route("-/secrets", "routes/workspace/secrets.tsx"), | |
| 29 | 31 | route("-/settings", "routes/workspace/settings.tsx"), | |
| 32 | + | route("-/agents", "routes/workspace/agents.tsx"), | |
| 33 | + | route("-/memory", "routes/workspace/memory.tsx"), | |
| 30 | 34 | // What the workspace will have across its projects. | |
| 31 | 35 | route("-/soon/:feature", "routes/workspace/soon.tsx"), | |
| 32 | 36 | ]), | |
| 33 | 37 | // Why a line is the way it is, fetched by the blame view. | |
| 34 | 38 | route(":owner/:repo/why/:hash", "routes/repo/why.ts"), | |
| 39 | + | // A project's agent runs as JSON, and stopping or messaging one. | |
| 40 | + | route(":owner/:repo/agents.json", "routes/repo/agents-live.ts"), | |
| 35 | 41 | // A project: its overview first, its repository's code under Code. The | |
| 36 | 42 | // 1:1 project of a repository has the repository's name, so every | |
| 37 | 43 | // repository address below keeps working. | |
| 49 | 55 | route("pulls/new", "routes/repo/pull-new.tsx"), | |
| 50 | 56 | route("pull/:number", "routes/repo/pull.tsx"), | |
| 51 | 57 | route("queue", "routes/repo/queue.tsx"), | |
| 58 | + | route("agents", "routes/repo/agents.tsx"), | |
| 59 | + | route("agents/runs/:id", "routes/repo/agents-run.tsx"), | |
| 60 | + | route("sessions", "routes/repo/sessions.tsx"), | |
| 61 | + | route("sessions/:number", "routes/repo/session.tsx"), | |
| 62 | + | route("memory", "routes/repo/memory.tsx"), | |
| 52 | 63 | route("actions", "routes/repo/actions.tsx"), | |
| 53 | 64 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 54 | 65 | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 62 | 73 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 63 | 74 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 64 | 75 | route("settings/deployments", "routes/repo/settings-deployments.tsx"), | |
| 76 | + | route("settings/domains", "routes/repo/settings-domains.tsx"), | |
| 65 | 77 | route("settings/dependencies", "routes/repo/settings-dependencies.tsx"), | |
| 66 | 78 | // What the project will have: one page for each Soon in its menu. | |
| 67 | 79 | route("soon/:feature", "routes/repo/soon.tsx"), |
| 1 | 1 | import { data } from "react-router"; | |
| 2 | 2 | ||
| 3 | + | import type { Route } from "./+types/not-found"; | |
| 4 | + | import { redirectIfRenamed } from "../lib/renamed.server"; | |
| 5 | + | ||
| 3 | 6 | /** | |
| 4 | 7 | * Any address no other route matches. Throwing the 404 from a route, rather | |
| 5 | 8 | * than leaving the router to, means the root loader still runs, so someone | |
| 6 | 9 | * signed in sees the page in their own sidebar and not the public frame. | |
| 10 | + | * | |
| 11 | + | * An address under a renamed workspace's old name is sent to the new one | |
| 12 | + | * first, so deep links keep working the same as its pages do. | |
| 7 | 13 | */ | |
| 8 | − | export function loader() { | |
| 14 | + | export async function loader({ request }: Route.LoaderArgs) { | |
| 15 | + | const first = new URL(request.url).pathname.split("/")[1]; | |
| 16 | + | if (first) await redirectIfRenamed(request, first); | |
| 9 | 17 | throw data(null, { status: 404 }); | |
| 10 | 18 | } | |
| 11 | 19 |
| 147 | 147 | </div> | |
| 148 | 148 | <p className="mt-1 text-sm text-muted"> | |
| 149 | 149 | Includes {DEPLOYMENTS_ALLOWANCE.apps} apps up at once, {(DEPLOYMENTS_ALLOWANCE.requests / 1e6).toLocaleString()}{" "} | |
| 150 | − | million requests and {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds a month; | |
| 150 | + | million requests, {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds and{" "} | |
| 151 | + | {DEPLOYMENTS_ALLOWANCE.customDomains} custom domains a month; | |
| 151 | 152 | builds, and usage past that, at the prices above. | |
| 152 | 153 | </p> | |
| 153 | 154 | </section> |
| 1 | + | /** | |
| 2 | + | * A project's agent runs as JSON, for the parts of other pages that show | |
| 3 | + | * them live (a pull request's Agent panel, the pull request list), and | |
| 4 | + | * the one place runs are stopped and messaged from. | |
| 5 | + | */ | |
| 6 | + | import { env } from "cloudflare:workers"; | |
| 7 | + | import { data } from "react-router"; | |
| 8 | + | ||
| 9 | + | import { RUN_KINDS, type RunKind, takesMessages } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | import type { Route } from "./+types/agents-live"; | |
| 12 | + | import { agents, work } from "../../lib/services.server"; | |
| 13 | + | import { assertSameOrigin, getViewer, roleIn } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 16 | + | const viewer = getViewer(context); | |
| 17 | + | const query = new URL(request.url).searchParams; | |
| 18 | + | const number = Number(query.get("number")); | |
| 19 | + | const kind = query.get("kind"); | |
| 20 | + | const found = await agents.listRuns(viewer, { | |
| 21 | + | repo: { namespace: params.owner, name: params.repo }, | |
| 22 | + | active: query.get("active") === "1", | |
| 23 | + | number: number > 0 ? number : undefined, | |
| 24 | + | kind: kind && RUN_KINDS.includes(kind as RunKind) ? (kind as RunKind) : undefined, | |
| 25 | + | limit: Math.min(Number(query.get("limit")) || 50, 200), | |
| 26 | + | }); | |
| 27 | + | if (!found.ok) throw data({ error: found.error.message }, { status: 404 }); | |
| 28 | + | return Response.json( | |
| 29 | + | { runs: found.value, member: roleIn(viewer, params.owner) != null }, | |
| 30 | + | { headers: { "cache-control": "no-store" } }, | |
| 31 | + | ); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 35 | + | assertSameOrigin(request); | |
| 36 | + | const viewer = getViewer(context); | |
| 37 | + | if (!viewer) return { ok: false, error: "Sign in first." }; | |
| 38 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 39 | + | const form = await request.formData(); | |
| 40 | + | const intent = String(form.get("intent") ?? ""); | |
| 41 | + | if (intent === "stop") { | |
| 42 | + | const stopped = await env.RUNNER.stopRun(viewer, path, String(form.get("run") ?? "")); | |
| 43 | + | return stopped.ok ? { ok: true, notice: "Stopped." } : { ok: false, error: stopped.error.message }; | |
| 44 | + | } | |
| 45 | + | if (intent === "message") { | |
| 46 | + | const number = Number(form.get("number")); | |
| 47 | + | const sent = await work.messageAgent(viewer, path, number, String(form.get("body") ?? "")); | |
| 48 | + | if (!sent.ok) return { ok: false, error: sent.error.message }; | |
| 49 | + | // Whether the agent reads it now, or its next run on the pull request does. | |
| 50 | + | const active = await agents.listRuns(viewer, { repo: path, number, active: true, limit: 1 }); | |
| 51 | + | const run = active.ok ? active.value[0] : undefined; | |
| 52 | + | return { | |
| 53 | + | ok: true, | |
| 54 | + | notice: | |
| 55 | + | run && takesMessages(run.kind) | |
| 56 | + | ? "Sent. The agent reads it at its next step." | |
| 57 | + | : "Sent. This run does not read messages while it works, so the agent's next run on this pull request gets it.", | |
| 58 | + | }; | |
| 59 | + | } | |
| 60 | + | return { ok: false, error: "Unknown action." }; | |
| 61 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.