feat(scaffold): 초기 프로젝트 골격을 구성한다

Gito를 platformless Git control plane으로 시작할 수 있도록 Go core, Flutter control surface, contracts, ops 규칙, 검증 헬퍼를 함께 구성한다.
This commit is contained in:
toki 2026-06-13 08:55:46 +09:00
commit e2bc7aa8b6
37 changed files with 1349 additions and 0 deletions

19
.gitignore vendored Normal file
View file

@ -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/

34
AGENTS.md Normal file
View file

@ -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.

59
README.md Normal file
View file

@ -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.

View file

@ -0,0 +1,2 @@
# Gito Private Rules

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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/`.

View file

@ -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` |

16
agent-test/local/rules.md Normal file
View file

@ -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.

8
apps/client/README.md Normal file
View file

@ -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.

32
apps/client/lib/main.dart Normal file
View file

@ -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'),
),
);
}
}

205
apps/client/pubspec.lock Normal file
View file

@ -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"

20
apps/client/pubspec.yaml Normal file
View file

@ -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

14
bin/build Executable file
View file

@ -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

17
bin/lint Executable file
View file

@ -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

16
bin/test Executable file
View file

@ -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

68
docs/architecture.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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. |

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

3
services/core/go.mod Normal file
View file

@ -0,0 +1,3 @@
module git.toki-labs.com/toki/gito/services/core
go 1.25.0

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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"
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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