From e2bc7aa8b6b36318a2bd0e97adb7c6a2dea0546d Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 13 Jun 2026 08:55:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(scaffold):=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B3=A8=EA=B2=A9?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gito를 platformless Git control plane으로 시작할 수 있도록 Go core, Flutter control surface, contracts, ops 규칙, 검증 헬퍼를 함께 구성한다. --- .gitignore | 19 ++ AGENTS.md | 34 +++ README.md | 59 +++++ agent-ops/rules/private/rules.md | 2 + .../rules/project/domain/client/rules.md | 13 ++ .../rules/project/domain/contracts/rules.md | 14 ++ agent-ops/rules/project/domain/core/rules.md | 25 +++ .../project/domain/workspace-ops/rules.md | 14 ++ agent-ops/rules/project/rules.md | 52 +++++ agent-test/local/rules.md | 16 ++ apps/client/README.md | 8 + apps/client/lib/main.dart | 32 +++ apps/client/pubspec.lock | 205 ++++++++++++++++++ apps/client/pubspec.yaml | 20 ++ bin/build | 14 ++ bin/lint | 17 ++ bin/test | 16 ++ docs/architecture.md | 68 ++++++ packages/contracts/README.md | 8 + .../contracts/notes/gito-control-plane.md | 100 +++++++++ services/core/cmd/server/main.go | 26 +++ services/core/cmd/shell/main.go | 20 ++ services/core/cmd/worker/main.go | 20 ++ services/core/go.mod | 3 + services/core/internal/agentshell/shell.go | 21 ++ services/core/internal/config/config.go | 47 ++++ services/core/internal/config/config_test.go | 45 ++++ services/core/internal/controlplane/router.go | 35 +++ .../core/internal/controlplane/router_test.go | 42 ++++ services/core/internal/core/model.go | 84 +++++++ services/core/internal/events/events.go | 28 +++ services/core/internal/gitengine/command.go | 64 ++++++ .../core/internal/gitengine/command_test.go | 51 +++++ services/core/internal/provider/provider.go | 21 ++ services/core/internal/storage/storage.go | 14 ++ services/core/internal/worker/runner.go | 25 +++ services/core/migrations/00001_initial.sql | 67 ++++++ 37 files changed, 1349 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 agent-ops/rules/private/rules.md create mode 100644 agent-ops/rules/project/domain/client/rules.md create mode 100644 agent-ops/rules/project/domain/contracts/rules.md create mode 100644 agent-ops/rules/project/domain/core/rules.md create mode 100644 agent-ops/rules/project/domain/workspace-ops/rules.md create mode 100644 agent-ops/rules/project/rules.md create mode 100644 agent-test/local/rules.md create mode 100644 apps/client/README.md create mode 100644 apps/client/lib/main.dart create mode 100644 apps/client/pubspec.lock create mode 100644 apps/client/pubspec.yaml create mode 100755 bin/build create mode 100755 bin/lint create mode 100755 bin/test create mode 100644 docs/architecture.md create mode 100644 packages/contracts/README.md create mode 100644 packages/contracts/notes/gito-control-plane.md create mode 100644 services/core/cmd/server/main.go create mode 100644 services/core/cmd/shell/main.go create mode 100644 services/core/cmd/worker/main.go create mode 100644 services/core/go.mod create mode 100644 services/core/internal/agentshell/shell.go create mode 100644 services/core/internal/config/config.go create mode 100644 services/core/internal/config/config_test.go create mode 100644 services/core/internal/controlplane/router.go create mode 100644 services/core/internal/controlplane/router_test.go create mode 100644 services/core/internal/core/model.go create mode 100644 services/core/internal/events/events.go create mode 100644 services/core/internal/gitengine/command.go create mode 100644 services/core/internal/gitengine/command_test.go create mode 100644 services/core/internal/provider/provider.go create mode 100644 services/core/internal/storage/storage.go create mode 100644 services/core/internal/worker/runner.go create mode 100644 services/core/migrations/00001_initial.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01c1827 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.DS_Store +.env +.env.* +!.env.example + +# Go +bin/gito-* +coverage.out + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ + +# Local runtime +.gito/ +tmp/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b25954b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Gito Agent Instructions + +Read this file before making changes in this repository. + +## Common Rules + +- Prefer the existing structure over creating new top-level patterns. +- Keep user-facing final answers in Korean unless the user asks otherwise. +- Check the relevant domain rule before code changes. +- Do not expand scope beyond the current request. +- Do not write secrets, tokens, passwords, private endpoints, or raw credentials to tracked files. +- Use `rg` or `rg --files` for searches when available. +- Use root helpers under `bin/` before ad hoc commands when they cover the task. + +## Required First Reads + +Read these files once per session when they exist: + +1. `agent-ops/rules/project/rules.md` +2. `agent-ops/rules/private/rules.md` + +## Domain Rules + +| Path | Domain | Rules | +| --- | --- | --- | +| `services/core/**` | core | `agent-ops/rules/project/domain/core/rules.md` | +| `apps/client/**` | client | `agent-ops/rules/project/domain/client/rules.md` | +| `packages/contracts/**` | contracts | `agent-ops/rules/project/domain/contracts/rules.md` | +| `bin/**`, `docs/**`, `AGENTS.md`, `README.md`, `agent-ops/**`, `agent-test/**` | workspace-ops | `agent-ops/rules/project/domain/workspace-ops/rules.md` | + +## Test Rules + +When validation is part of the task, read `agent-test/local/rules.md` first. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8bebcf0 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Gito + +Gito is a platformless Git control plane for agent-driven development workflows. + +It manages registered repositories, workspace leases, Git operations, agent-shell +execution, provider adapters, and normalized events. Git itself stays +platform-neutral; GitHub, GitLab, Gitea, Plane, Jira, and similar systems are +integrated through adapters. + +## Direction + +- Server: Go +- Control surface: Flutter app and `gitoctl` in future slices +- State: PostgreSQL as source of truth +- Event acceleration: Redis optional, not required for the first slice +- Internal transport: proto-socket first +- REST: health/readiness, smoke/curl, provider callbacks, simple admin bootstrap +- gRPC: intentionally excluded from the initial design + +## Runtime Shape + +```text +Flutter UI / gitoctl + | +Control Plane + | +Core Domain + | +Worker / Scheduler + | +Agent Shell / IOP / Git Engine + | +Provider Adapters +``` + +## Repository Layout + +```text +apps/client/ Flutter control surface scaffold +services/core/ Go control plane, worker, shell commands +packages/contracts/ proto-socket, REST, event, and provider contract notes +docs/ Architecture and operation notes +bin/ Root test/lint/build helpers +agent-ops/ Agent operating rules +agent-test/ Local validation rules +``` + +## First Commands + +```sh +bin/test +bin/lint +bin/build +``` + +The initial Go scaffold is intentionally small but buildable. Flutter platform +directories are not generated yet; `apps/client` currently records the control +surface boundary and a minimal Dart entry point. + diff --git a/agent-ops/rules/private/rules.md b/agent-ops/rules/private/rules.md new file mode 100644 index 0000000..1e2d0a0 --- /dev/null +++ b/agent-ops/rules/private/rules.md @@ -0,0 +1,2 @@ +# Gito Private Rules + diff --git a/agent-ops/rules/project/domain/client/rules.md b/agent-ops/rules/project/domain/client/rules.md new file mode 100644 index 0000000..b7cd429 --- /dev/null +++ b/agent-ops/rules/project/domain/client/rules.md @@ -0,0 +1,13 @@ +# client + +## Scope + +The client domain covers the Flutter control surface under `apps/client`. + +## Rules + +- The client is a control surface, not a Git execution runtime. +- Prefer proto-socket for operation requests, events, and log streams. +- REST should be used only for health/readiness or compatibility endpoints. +- Keep platform/provider-specific presentation behind adapter-friendly models. + diff --git a/agent-ops/rules/project/domain/contracts/rules.md b/agent-ops/rules/project/domain/contracts/rules.md new file mode 100644 index 0000000..ba9cdda --- /dev/null +++ b/agent-ops/rules/project/domain/contracts/rules.md @@ -0,0 +1,14 @@ +# contracts + +## Scope + +The contracts domain covers `packages/contracts`. + +## Rules + +- Record transport-independent DTOs before binding them to one transport. +- proto-socket is the first internal transport target. +- REST exceptions must be documented. +- gRPC is out of scope until explicitly added. +- Contract notes must not include raw secrets or private credentials. + diff --git a/agent-ops/rules/project/domain/core/rules.md b/agent-ops/rules/project/domain/core/rules.md new file mode 100644 index 0000000..7eccb7e --- /dev/null +++ b/agent-ops/rules/project/domain/core/rules.md @@ -0,0 +1,25 @@ +# core + +## Scope + +The core domain covers the Go control plane, worker, agent-shell command, core +models, platformless Git engine, provider adapters, storage, migrations, and +events. + +## Included Paths + +- `services/core/cmd/**` +- `services/core/internal/**` +- `services/core/migrations/**` +- `services/core/go.mod` + +## Rules + +- Keep provider-specific API details out of `internal/core`. +- Keep GitHub/GitLab/Gitea/Plane/Jira calls behind `internal/provider`. +- Keep platformless Git operations in `internal/gitengine`. +- Agent command execution belongs in `internal/agentshell`. +- Store durable state and audit trails in PostgreSQL first. +- Redis, if added, is an acceleration layer and not the source of truth. +- Large behavior changes require tests. + diff --git a/agent-ops/rules/project/domain/workspace-ops/rules.md b/agent-ops/rules/project/domain/workspace-ops/rules.md new file mode 100644 index 0000000..0096a92 --- /dev/null +++ b/agent-ops/rules/project/domain/workspace-ops/rules.md @@ -0,0 +1,14 @@ +# workspace-ops + +## Scope + +Workspace operations cover root helpers, docs, agent rules, test rules, and +repository metadata. + +## Rules + +- Keep helper commands simple and runnable from the repository root. +- Do not commit local secret files. +- Do not add generated build outputs. +- Prefer documenting exceptions in `docs/` or `packages/contracts/notes/`. + diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md new file mode 100644 index 0000000..08eeece --- /dev/null +++ b/agent-ops/rules/project/rules.md @@ -0,0 +1,52 @@ +# Gito Project Rules + +## Language + +- User-facing final answers default to Korean. +- Code identifiers, paths, commands, and commit scopes stay in the language used + by the project. + +## Project Summary + +Gito is a platformless Git control plane for agent-driven development. It owns +repo registry, workspace leases, Git operations, agent-shell execution, +provider-neutral change requests, provider adapters, and normalized events. + +## Main Structure + +- `services/core/` - Go server, worker, shell, core domain, storage, adapters. +- `apps/client/` - Flutter control surface. +- `packages/contracts/` - proto-socket, REST, event, and DTO contract notes. +- `bin/` - root test/lint/build helpers. +- `docs/` - architecture and operation documents. +- `agent-ops/` - agent rules. +- `agent-test/` - local validation rules. + +## Stack + +- Backend: Go, PostgreSQL, optional Redis. +- Client: Flutter/Dart. +- Internal transport: proto-socket first. +- REST: health/readiness, provider callbacks, smoke/curl, bootstrap. +- gRPC: excluded from the initial design. + +## Conventions + +- Prefer `bin/test`, `bin/lint`, and `bin/build` from the repository root. +- Keep Git operations platformless unless a provider adapter is explicitly in + scope. +- Provider APIs belong behind adapter boundaries. +- Agent-shell owns local command execution; control plane and core should not + run arbitrary workspace commands directly. +- Credential values must be referenced by `credential_ref` or local secret files, + never stored in tracked docs. + +## Domain Map + +| Path | Domain | Rules | +| --- | --- | --- | +| `services/core/**` | core | `agent-ops/rules/project/domain/core/rules.md` | +| `apps/client/**` | client | `agent-ops/rules/project/domain/client/rules.md` | +| `packages/contracts/**` | contracts | `agent-ops/rules/project/domain/contracts/rules.md` | +| `bin/**`, `docs/**`, `AGENTS.md`, `README.md`, `agent-ops/**`, `agent-test/**` | workspace-ops | `agent-ops/rules/project/domain/workspace-ops/rules.md` | + diff --git a/agent-test/local/rules.md b/agent-test/local/rules.md new file mode 100644 index 0000000..784c99e --- /dev/null +++ b/agent-test/local/rules.md @@ -0,0 +1,16 @@ +# Local Test Rules + +Read this file before validation work. + +## Baseline + +- Core: `cd services/core && go test ./...` +- Go lint: `cd services/core && go vet ./...` +- Root: `bin/test`, `bin/lint`, `bin/build` +- Flutter: run `flutter test` and `flutter analyze --no-fatal-infos` when the + Flutter toolchain is available and client changes are in scope. + +## Reporting + +Report executed commands, pass/fail result, skipped tools, and remaining risk. + diff --git a/apps/client/README.md b/apps/client/README.md new file mode 100644 index 0000000..9fd2e78 --- /dev/null +++ b/apps/client/README.md @@ -0,0 +1,8 @@ +# Gito Client + +Flutter control surface for Gito. + +The first slice keeps the client scaffold light. It should consume Gito through +proto-socket for operation requests, event streams, and log streams. REST is only +for health/readiness and operational compatibility endpoints. + diff --git a/apps/client/lib/main.dart b/apps/client/lib/main.dart new file mode 100644 index 0000000..5a055c4 --- /dev/null +++ b/apps/client/lib/main.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const GitoClientApp()); +} + +class GitoClientApp extends StatelessWidget { + const GitoClientApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Gito', + theme: ThemeData(colorSchemeSeed: Colors.teal), + home: const GitoHomePage(), + ); + } +} + +class GitoHomePage extends StatelessWidget { + const GitoHomePage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text('Gito control surface'), + ), + ); + } +} + diff --git a/apps/client/pubspec.lock b/apps/client/pubspec.lock new file mode 100644 index 0000000..7002b0c --- /dev/null +++ b/apps/client/pubspec.lock @@ -0,0 +1,205 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml new file mode 100644 index 0000000..ff0bf94 --- /dev/null +++ b/apps/client/pubspec.yaml @@ -0,0 +1,20 @@ +name: gito_client +description: Flutter control surface for Gito. +publish_to: none +version: 0.1.0 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + diff --git a/bin/build b/bin/build new file mode 100755 index 0000000..c622687 --- /dev/null +++ b/bin/build @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +OUT_DIR="$ROOT_DIR/bin" + +if command -v go >/dev/null 2>&1; then + (cd "$ROOT_DIR/services/core" && go build -o "$OUT_DIR/gito-server" ./cmd/server) + (cd "$ROOT_DIR/services/core" && go build -o "$OUT_DIR/gito-worker" ./cmd/worker) + (cd "$ROOT_DIR/services/core" && go build -o "$OUT_DIR/gito-shell" ./cmd/shell) +else + echo "skip services/core: go not found" +fi + diff --git a/bin/lint b/bin/lint new file mode 100755 index 0000000..4bf7bd0 --- /dev/null +++ b/bin/lint @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +if command -v go >/dev/null 2>&1; then + (cd "$ROOT_DIR/services/core" && go vet ./...) +else + echo "skip services/core: go not found" +fi + +if command -v flutter >/dev/null 2>&1 && [ -f "$ROOT_DIR/apps/client/pubspec.yaml" ]; then + (cd "$ROOT_DIR/apps/client" && flutter analyze --no-fatal-infos) +else + echo "skip apps/client: flutter not found or app not generated" +fi + diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..dab627a --- /dev/null +++ b/bin/test @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +if command -v go >/dev/null 2>&1; then + (cd "$ROOT_DIR/services/core" && go test ./...) +else + echo "skip services/core: go not found" +fi + +if command -v flutter >/dev/null 2>&1 && [ -f "$ROOT_DIR/apps/client/pubspec.yaml" ] && [ -d "$ROOT_DIR/apps/client/test" ]; then + (cd "$ROOT_DIR/apps/client" && flutter test) +else + echo "skip apps/client: flutter not found or no test scaffold" +fi diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f8b03eb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,68 @@ +# Architecture + +Gito starts as a modular monolith with separable process roles. The codebase can +run as one service at first, but its boundaries should allow separate server, +worker, and shell deployments later. + +## Layers + +### Control Plane + +- Owns proto-socket and REST surfaces. +- Accepts user, automation, and provider callback requests. +- Applies auth, policy, and approval gates. +- Creates operations and exposes event streams. +- Does not execute Git commands directly. + +### Core Domain + +- Owns repository registry, workspace leases, operations, revision cursors, + idempotency keys, audit records, and normalized events. +- Knows provider-neutral concepts such as `Repo`, `WorkspaceLease`, + `GitOperation`, `RevisionEvent`, and `ChangeRequest`. +- Does not know GitHub, GitLab, Gitea, Plane, Jira, or IOP HTTP details. + +### Worker + +- Picks pending operations. +- Coordinates workspace leases. +- Calls agent-shell for filesystem and command execution. +- Records operation outcomes and emits events. +- Handles retry and backoff policy. + +### Agent Shell + +- Runs on a machine that owns local workspace access. +- Executes Git CLI and IOP CLI commands. +- Streams logs, handles cancellation, and enforces dirty-workspace guards. +- Connects outbound to the control plane where possible. + +### Git Engine + +- Platformless Git operation layer. +- Supports clone, fetch, pull, checkout, status, diff, commit, push, branch, tag, + and revision scans. +- Must be testable against local bare repositories without GitHub, GitLab, or + Gitea. + +### Provider Adapters + +- Own platform APIs and DTOs. +- Map GitHub PR, GitLab MR, and Gitea PR into provider-neutral + `ChangeRequest` operations. +- Treat webhooks as wakeup signals. Final state must be verified through Git + revision or provider read APIs. + +## Transport + +- proto-socket is the default internal runtime transport. +- REST is reserved for health/readiness, provider callbacks, smoke/curl, and + simple bootstrap endpoints. +- gRPC is intentionally out of scope for the initial architecture. + +## Event Policy + +All external side effects should produce durable operation and event records in +PostgreSQL. Redis may be added later for fanout or stream acceleration, but it is +not the source of truth. + diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..2cdb157 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,8 @@ +# Gito Contracts + +This package records transport-independent contract candidates for Gito. + +Use it for proto-socket channel/action notes, REST compatibility maps, event +schemas, provider-neutral DTOs, and compatibility notes shared by the Go core and +Flutter client. + diff --git a/packages/contracts/notes/gito-control-plane.md b/packages/contracts/notes/gito-control-plane.md new file mode 100644 index 0000000..ddc1868 --- /dev/null +++ b/packages/contracts/notes/gito-control-plane.md @@ -0,0 +1,100 @@ +# Gito Control Plane Contract Candidates + +## Transport Policy + +- Internal runtime calls use proto-socket first. +- REST remains for health/readiness, provider callbacks, smoke/curl, and simple + bootstrap endpoints. +- gRPC is excluded from the first design. + +## proto-socket Channels + +| Channel | Purpose | +| --- | --- | +| `repo` | Register, inspect, and list managed repositories. | +| `workspace` | Lease, release, and inspect workspace slots. | +| `operation` | Create, cancel, inspect, and stream operations. | +| `git` | Request platformless Git operations. | +| `change_request` | Manage provider-neutral PR/MR operations. | +| `agent_shell` | Shell heartbeat, command dispatch, and log streaming. | +| `event` | Subscribe to normalized events. | + +## Core DTO Candidates + +### Repo + +| Field | Meaning | +| --- | --- | +| `id` | Stable Gito repo id. | +| `name` | Display name. | +| `remote_url` | Git remote URL. | +| `default_branch` | Default source-of-truth branch. | +| `workspace_root` | Local workspace root for slots. | +| `credential_ref` | Reference to credentials, never the raw secret. | + +### WorkspaceLease + +| Field | Meaning | +| --- | --- | +| `id` | Lease id. | +| `repo_id` | Managed repo id. | +| `slot` | Zero-padded slot index. | +| `path` | Local workspace path. | +| `state` | `available`, `leased`, `dirty`, or `error`. | +| `expires_at` | Optional lease expiry. | + +### Operation + +| Field | Meaning | +| --- | --- | +| `id` | Operation id. | +| `repo_id` | Target repo. | +| `type` | `clone`, `fetch`, `commit`, `push`, `agent_run`, etc. | +| `state` | `queued`, `running`, `succeeded`, `failed`, `cancelled`. | +| `idempotency_key` | Duplicate guard. | +| `created_by` | Actor id or system source. | + +### RevisionEvent + +| Field | Meaning | +| --- | --- | +| `repo_id` | Target repo. | +| `branch` | Branch name. | +| `before` | Previous revision. | +| `after` | New revision. | +| `changed_files` | Changed file paths and change types. | +| `observed_at` | Observation timestamp. | + +### ChangeRequest + +Provider-neutral abstraction for GitHub PR, GitLab MR, Gitea PR, and similar +platform features. + +| Field | Meaning | +| --- | --- | +| `provider` | `github`, `gitlab`, `gitea`, etc. | +| `external_id` | Provider PR/MR id. | +| `repo_id` | Target repo. | +| `source_branch` | Source branch. | +| `target_branch` | Target branch. | +| `state` | `open`, `merged`, `closed`, `draft`, etc. | + +## Normalized Events + +| Event | Meaning | +| --- | --- | +| `repo.changed` | Repo registry changed. | +| `branch.updated` | Branch revision changed. | +| `workspace.leased` | Workspace lease acquired. | +| `workspace.released` | Workspace lease released. | +| `workspace.dirty` | Workspace has uncommitted changes. | +| `operation.started` | Operation execution started. | +| `operation.completed` | Operation succeeded. | +| `operation.failed` | Operation failed. | +| `agent.run.started` | Agent execution started. | +| `agent.run.completed` | Agent execution completed. | +| `change_request.opened` | PR/MR-like object opened. | +| `change_request.updated` | PR/MR-like object changed. | +| `change_request.merged` | PR/MR-like object merged. | +| `provider.webhook.received` | Provider webhook received. | + diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go new file mode 100644 index 0000000..99674fb --- /dev/null +++ b/services/core/cmd/server/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log/slog" + "net/http" + "os" + + "git.toki-labs.com/toki/gito/services/core/internal/config" + "git.toki-labs.com/toki/gito/services/core/internal/controlplane" +) + +func main() { + cfg := config.Load() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: controlplane.NewRouter(cfg, logger), + } + + logger.Info("gito control plane listening", "addr", cfg.HTTPAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} diff --git a/services/core/cmd/shell/main.go b/services/core/cmd/shell/main.go new file mode 100644 index 0000000..c3088b3 --- /dev/null +++ b/services/core/cmd/shell/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log/slog" + "os" + + "git.toki-labs.com/toki/gito/services/core/internal/agentshell" + "git.toki-labs.com/toki/gito/services/core/internal/config" +) + +func main() { + cfg := config.Load() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + shell := agentshell.New(cfg, logger) + if err := shell.Run(); err != nil { + logger.Error("agent shell stopped", "error", err) + os.Exit(1) + } +} diff --git a/services/core/cmd/worker/main.go b/services/core/cmd/worker/main.go new file mode 100644 index 0000000..e76ca4e --- /dev/null +++ b/services/core/cmd/worker/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log/slog" + "os" + + "git.toki-labs.com/toki/gito/services/core/internal/config" + "git.toki-labs.com/toki/gito/services/core/internal/worker" +) + +func main() { + cfg := config.Load() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + runner := worker.NewRunner(cfg, logger) + if err := runner.Run(); err != nil { + logger.Error("worker stopped", "error", err) + os.Exit(1) + } +} diff --git a/services/core/go.mod b/services/core/go.mod new file mode 100644 index 0000000..e0ec4be --- /dev/null +++ b/services/core/go.mod @@ -0,0 +1,3 @@ +module git.toki-labs.com/toki/gito/services/core + +go 1.25.0 diff --git a/services/core/internal/agentshell/shell.go b/services/core/internal/agentshell/shell.go new file mode 100644 index 0000000..533e649 --- /dev/null +++ b/services/core/internal/agentshell/shell.go @@ -0,0 +1,21 @@ +package agentshell + +import ( + "log/slog" + + "git.toki-labs.com/toki/gito/services/core/internal/config" +) + +type Shell struct { + cfg config.Config + logger *slog.Logger +} + +func New(cfg config.Config, logger *slog.Logger) *Shell { + return &Shell{cfg: cfg, logger: logger} +} + +func (s *Shell) Run() error { + s.logger.Info("agent shell scaffold ready", "shell_id", s.cfg.ShellID) + return nil +} diff --git a/services/core/internal/config/config.go b/services/core/internal/config/config.go new file mode 100644 index 0000000..266cdf5 --- /dev/null +++ b/services/core/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + AppEnv string + HTTPAddr string + DatabaseURL string + RedisURL string + ProtoSocketPath string + WorkerEnabled bool + ShellID string +} + +func Load() Config { + return Config{ + AppEnv: getEnv("APP_ENV", "local"), + HTTPAddr: getEnv("HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + RedisURL: os.Getenv("REDIS_URL"), + ProtoSocketPath: getEnv("PROTO_SOCKET_PATH", "/proto-socket"), + WorkerEnabled: getEnvBool("WORKER_ENABLED", true), + ShellID: getEnv("GITO_SHELL_ID", "local-shell"), + } +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getEnvBool(key string, fallback bool) bool { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/services/core/internal/config/config_test.go b/services/core/internal/config/config_test.go new file mode 100644 index 0000000..6a4c595 --- /dev/null +++ b/services/core/internal/config/config_test.go @@ -0,0 +1,45 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + t.Setenv("APP_ENV", "") + t.Setenv("HTTP_ADDR", "") + t.Setenv("PROTO_SOCKET_PATH", "") + + cfg := Load() + if cfg.AppEnv != "local" { + t.Fatalf("AppEnv: got %q, want local", cfg.AppEnv) + } + if cfg.HTTPAddr != ":8080" { + t.Fatalf("HTTPAddr: got %q, want :8080", cfg.HTTPAddr) + } + if cfg.ProtoSocketPath != "/proto-socket" { + t.Fatalf("ProtoSocketPath: got %q, want /proto-socket", cfg.ProtoSocketPath) + } + if !cfg.WorkerEnabled { + t.Fatal("WorkerEnabled default should be true") + } +} + +func TestLoadEnvOverrides(t *testing.T) { + t.Setenv("APP_ENV", "test") + t.Setenv("HTTP_ADDR", ":18080") + t.Setenv("PROTO_SOCKET_PATH", "/gito") + t.Setenv("WORKER_ENABLED", "false") + t.Setenv("GITO_SHELL_ID", "runner-1") + + cfg := Load() + if cfg.AppEnv != "test" || cfg.HTTPAddr != ":18080" { + t.Fatalf("unexpected config: %+v", cfg) + } + if cfg.ProtoSocketPath != "/gito" { + t.Fatalf("ProtoSocketPath: got %q", cfg.ProtoSocketPath) + } + if cfg.WorkerEnabled { + t.Fatal("WorkerEnabled should be false") + } + if cfg.ShellID != "runner-1" { + t.Fatalf("ShellID: got %q", cfg.ShellID) + } +} diff --git a/services/core/internal/controlplane/router.go b/services/core/internal/controlplane/router.go new file mode 100644 index 0000000..ceda675 --- /dev/null +++ b/services/core/internal/controlplane/router.go @@ -0,0 +1,35 @@ +package controlplane + +import ( + "encoding/json" + "log/slog" + "net/http" + + "git.toki-labs.com/toki/gito/services/core/internal/config" +) + +func NewRouter(cfg config.Config, logger *slog.Logger) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ready", + "env": cfg.AppEnv, + }) + }) + mux.HandleFunc(cfg.ProtoSocketPath, func(w http.ResponseWriter, r *http.Request) { + logger.Info("proto-socket endpoint placeholder", "path", r.URL.Path) + writeJSON(w, http.StatusNotImplemented, map[string]string{ + "error": "proto-socket endpoint is not implemented yet", + }) + }) + return mux +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/services/core/internal/controlplane/router_test.go b/services/core/internal/controlplane/router_test.go new file mode 100644 index 0000000..81af024 --- /dev/null +++ b/services/core/internal/controlplane/router_test.go @@ -0,0 +1,42 @@ +package controlplane + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "git.toki-labs.com/toki/gito/services/core/internal/config" +) + +func TestHealthz(t *testing.T) { + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d", rec.Code) + } + var body map[string]string + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("body: %#v", body) + } +} + +func TestProtoSocketPlaceholder(t *testing.T) { + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/proto-socket", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotImplemented { + t.Fatalf("status: got %d", rec.Code) + } +} diff --git a/services/core/internal/core/model.go b/services/core/internal/core/model.go new file mode 100644 index 0000000..d917422 --- /dev/null +++ b/services/core/internal/core/model.go @@ -0,0 +1,84 @@ +package core + +import "time" + +type Repo struct { + ID string + Name string + RemoteURL string + DefaultBranch string + WorkspaceRoot string + CredentialRef string +} + +type WorkspaceLease struct { + ID string + RepoID string + Slot string + Path string + State WorkspaceState + ExpiresAt *time.Time +} + +type WorkspaceState string + +const ( + WorkspaceAvailable WorkspaceState = "available" + WorkspaceLeased WorkspaceState = "leased" + WorkspaceDirty WorkspaceState = "dirty" + WorkspaceError WorkspaceState = "error" +) + +type Operation struct { + ID string + RepoID string + Type OperationType + State OperationState + IdempotencyKey string + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + +type OperationType string + +const ( + OperationClone OperationType = "clone" + OperationFetch OperationType = "fetch" + OperationCommit OperationType = "commit" + OperationPush OperationType = "push" + OperationAgentRun OperationType = "agent_run" +) + +type OperationState string + +const ( + OperationQueued OperationState = "queued" + OperationRunning OperationState = "running" + OperationSucceeded OperationState = "succeeded" + OperationFailed OperationState = "failed" + OperationCancelled OperationState = "cancelled" +) + +type RevisionEvent struct { + RepoID string + Branch string + Before string + After string + ChangedFiles []ChangedFile + ObservedAt time.Time +} + +type ChangedFile struct { + Path string + ChangeType string +} + +type ChangeRequest struct { + Provider string + ExternalID string + RepoID string + SourceBranch string + TargetBranch string + State string +} diff --git a/services/core/internal/events/events.go b/services/core/internal/events/events.go new file mode 100644 index 0000000..106778e --- /dev/null +++ b/services/core/internal/events/events.go @@ -0,0 +1,28 @@ +package events + +import "time" + +type Event struct { + ID string + Type string + Subject string + Payload []byte + CreatedAt time.Time +} + +const ( + RepoChanged = "repo.changed" + BranchUpdated = "branch.updated" + WorkspaceLeased = "workspace.leased" + WorkspaceReleased = "workspace.released" + WorkspaceDirty = "workspace.dirty" + OperationStarted = "operation.started" + OperationCompleted = "operation.completed" + OperationFailed = "operation.failed" + AgentRunStarted = "agent.run.started" + AgentRunCompleted = "agent.run.completed" + ChangeRequestOpened = "change_request.opened" + ChangeRequestUpdated = "change_request.updated" + ChangeRequestMerged = "change_request.merged" + ProviderWebhook = "provider.webhook.received" +) diff --git a/services/core/internal/gitengine/command.go b/services/core/internal/gitengine/command.go new file mode 100644 index 0000000..c022aa8 --- /dev/null +++ b/services/core/internal/gitengine/command.go @@ -0,0 +1,64 @@ +package gitengine + +import ( + "errors" + "os/exec" + "strings" +) + +var ErrInvalidGitInput = errors.New("invalid git input") + +type CommandRunner interface { + Run(workdir string, args ...string) (string, error) +} + +type CLI struct{} + +func (CLI) Run(workdir string, args ...string) (string, error) { + if len(args) == 0 { + return "", ErrInvalidGitInput + } + cmd := exec.Command("git", args...) + if strings.TrimSpace(workdir) != "" { + cmd.Dir = workdir + } + out, err := cmd.CombinedOutput() + return string(out), err +} + +type StatusResult struct { + Clean bool + Output string +} + +func Status(runner CommandRunner, workdir string) (StatusResult, error) { + output, err := runner.Run(workdir, "status", "--porcelain") + if err != nil { + return StatusResult{}, err + } + return StatusResult{ + Clean: strings.TrimSpace(output) == "", + Output: output, + }, nil +} + +func ChangedFiles(runner CommandRunner, workdir, before, after string) ([]string, error) { + before = strings.TrimSpace(before) + after = strings.TrimSpace(after) + if before == "" || after == "" { + return nil, ErrInvalidGitInput + } + output, err := runner.Run(workdir, "diff", "--name-only", before+".."+after) + if err != nil { + return nil, err + } + lines := strings.Split(output, "\n") + files := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files, nil +} diff --git a/services/core/internal/gitengine/command_test.go b/services/core/internal/gitengine/command_test.go new file mode 100644 index 0000000..d3b1875 --- /dev/null +++ b/services/core/internal/gitengine/command_test.go @@ -0,0 +1,51 @@ +package gitengine + +import ( + "errors" + "reflect" + "testing" +) + +type fakeRunner struct { + output string + err error + args []string +} + +func (f *fakeRunner) Run(_ string, args ...string) (string, error) { + f.args = append([]string(nil), args...) + return f.output, f.err +} + +func TestStatusClean(t *testing.T) { + runner := &fakeRunner{output: "\n"} + result, err := Status(runner, "/repo") + if err != nil { + t.Fatalf("Status: %v", err) + } + if !result.Clean { + t.Fatal("expected clean status") + } + if !reflect.DeepEqual(runner.args, []string{"status", "--porcelain"}) { + t.Fatalf("args: %#v", runner.args) + } +} + +func TestChangedFiles(t *testing.T) { + runner := &fakeRunner{output: "README.md\nservices/core/main.go\n\n"} + files, err := ChangedFiles(runner, "/repo", "abc", "def") + if err != nil { + t.Fatalf("ChangedFiles: %v", err) + } + want := []string{"README.md", "services/core/main.go"} + if !reflect.DeepEqual(files, want) { + t.Fatalf("files: got %#v want %#v", files, want) + } +} + +func TestChangedFilesRequiresRevisions(t *testing.T) { + _, err := ChangedFiles(&fakeRunner{}, "/repo", "", "def") + if !errors.Is(err, ErrInvalidGitInput) { + t.Fatalf("expected ErrInvalidGitInput, got %v", err) + } +} diff --git a/services/core/internal/provider/provider.go b/services/core/internal/provider/provider.go new file mode 100644 index 0000000..8b8b197 --- /dev/null +++ b/services/core/internal/provider/provider.go @@ -0,0 +1,21 @@ +package provider + +import ( + "context" + + "git.toki-labs.com/toki/gito/services/core/internal/core" +) + +type ChangeRequestAdapter interface { + CreateChangeRequest(ctx context.Context, input core.ChangeRequest) (core.ChangeRequest, error) + UpdateChangeRequest(ctx context.Context, input core.ChangeRequest) (core.ChangeRequest, error) + CommentChangeRequest(ctx context.Context, id string, body string) error + CloseChangeRequest(ctx context.Context, id string) error +} + +type WebhookEvent struct { + Provider string + EventType string + ExternalID string + Payload []byte +} diff --git a/services/core/internal/storage/storage.go b/services/core/internal/storage/storage.go new file mode 100644 index 0000000..624fffd --- /dev/null +++ b/services/core/internal/storage/storage.go @@ -0,0 +1,14 @@ +package storage + +import "context" + +type Store struct{} + +func New() *Store { + return &Store{} +} + +func (s *Store) Ping(ctx context.Context) error { + _ = ctx + return nil +} diff --git a/services/core/internal/worker/runner.go b/services/core/internal/worker/runner.go new file mode 100644 index 0000000..9ad2e14 --- /dev/null +++ b/services/core/internal/worker/runner.go @@ -0,0 +1,25 @@ +package worker + +import ( + "log/slog" + + "git.toki-labs.com/toki/gito/services/core/internal/config" +) + +type Runner struct { + cfg config.Config + logger *slog.Logger +} + +func NewRunner(cfg config.Config, logger *slog.Logger) *Runner { + return &Runner{cfg: cfg, logger: logger} +} + +func (r *Runner) Run() error { + if !r.cfg.WorkerEnabled { + r.logger.Info("worker disabled") + return nil + } + r.logger.Info("worker scaffold ready") + return nil +} diff --git a/services/core/migrations/00001_initial.sql b/services/core/migrations/00001_initial.sql new file mode 100644 index 0000000..74de8dc --- /dev/null +++ b/services/core/migrations/00001_initial.sql @@ -0,0 +1,67 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS repos ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + remote_url TEXT NOT NULL, + default_branch TEXT NOT NULL, + workspace_root TEXT NOT NULL, + credential_ref TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS workspace_leases ( + id TEXT PRIMARY KEY, + repo_id TEXT NOT NULL REFERENCES repos(id), + slot TEXT NOT NULL, + path TEXT NOT NULL, + state TEXT NOT NULL, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (repo_id, slot) +); + +CREATE TABLE IF NOT EXISTS operations ( + id TEXT PRIMARY KEY, + repo_id TEXT NOT NULL REFERENCES repos(id), + type TEXT NOT NULL, + state TEXT NOT NULL, + idempotency_key TEXT, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_idempotency + ON operations (idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS operation_events ( + id TEXT PRIMARY KEY, + operation_id TEXT REFERENCES operations(id), + type TEXT NOT NULL, + subject TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS revision_cursors ( + repo_id TEXT NOT NULL REFERENCES repos(id), + branch TEXT NOT NULL, + revision TEXT NOT NULL, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (repo_id, branch) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS revision_cursors; +DROP TABLE IF EXISTS operation_events; +DROP TABLE IF EXISTS operations; +DROP TABLE IF EXISTS workspace_leases; +DROP TABLE IF EXISTS repos; +-- +goose StatementEnd +