Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b0adc29
Add automatic service placement
techulus-agent Jul 22, 2026
e656294
Harden automatic placement recovery
techulus-agent Jul 23, 2026
15fc8d7
Complete automatic placement contract switch
techulus-agent Jul 23, 2026
b8fd1ce
Combine API keys into security settings
ampagent Jul 23, 2026
c4b816a
Enable deploy for automatic placement
techulus-agent Jul 23, 2026
12d9996
Remove legacy API keys tab alias
ampagent Jul 23, 2026
6903036
Merge pull request #190 from techulus/ui/combine-security-api-keys
arjunkomath Jul 23, 2026
c775710
Allow cross-platform build fallback
techulus-agent Jul 23, 2026
c4a4e26
Constrain placement mode picker width
techulus-agent Jul 23, 2026
57c56e5
Simplify automatic placement controls
techulus-agent Jul 23, 2026
5566ad2
Remove automatic placement stabilization delay
techulus-agent Jul 23, 2026
97cdfb0
Read rollout configuration after lock wait
techulus-agent Jul 23, 2026
cf09d53
Merge pull request #189 from techulus/feat/autoplacement
arjunkomath Jul 23, 2026
7707539
Add GPT-5.6 Sol agent mode
ampagent Jul 23, 2026
aa7a82c
Prevent obsolete rollout revival
techulus-agent Jul 23, 2026
757d87e
Merge pull request #193 from techulus/fix/rollout-intent-ordering
arjunkomath Jul 23, 2026
961473c
Add minimalism rules to Sol agent
ampagent Jul 23, 2026
5b984c0
Rename Sol agent to sol-xhigh
ampagent Jul 23, 2026
d62185d
Merge pull request #192 from techulus/feat/sol-agent
arjunkomath Jul 23, 2026
26fea19
refactor(cli): remove audited complexity
ampagent Jul 23, 2026
13b5f4c
agent: remove dead and redundant helpers
ampagent Jul 23, 2026
adde3e9
agent: consolidate DNS and Victoria reporting
ampagent Jul 23, 2026
278ed3e
agent: deduplicate lifecycle and static config
ampagent Jul 23, 2026
466740b
test(cli): simplify test setup
ampagent Jul 23, 2026
60eb7c3
chore: simplify control plane updater
ampagent Jul 23, 2026
fada509
test(agent): remove dead fixture scaffolding
ampagent Jul 23, 2026
7f8e0a5
chore(web): remove unused control plane code
ampagent Jul 23, 2026
113efa6
test(web): trim redundant control plane coverage
ampagent Jul 23, 2026
9292113
Merge pull request #194 from techulus/refactor/ponytail-cli-audit
arjunkomath Jul 23, 2026
b8a9fc1
Merge pull request #196 from techulus/chore/simplify-updater
arjunkomath Jul 23, 2026
ba5c1c1
Merge pull request #195 from techulus/agent/ponytail-cleanup
arjunkomath Jul 23, 2026
dd985c7
Merge pull request #197 from techulus/chore/ponytail-web-cleanup
arjunkomath Jul 23, 2026
746993c
chore: remove .amp custom plugins folder
ampagent Jul 24, 2026
b8e7c30
Merge pull request #198 from techulus/chore/remove-amp-plugins
arjunkomath Jul 24, 2026
851a33d
Remove public agent update script
ampagent Jul 24, 2026
ecaffdb
Merge pull request #199 from techulus/remove-public-agent-updater
arjunkomath Jul 24, 2026
35f63c4
Align rollout status badge widths
techulus-agent Jul 24, 2026
05198f6
Merge pull request #200 from techulus/fix/consistent-rollout-status-b…
arjunkomath Jul 24, 2026
7b6a7cd
Fix rollout health-check timeouts from transient container states
arjunkomath Jul 24, 2026
9fafbef
Use separate config for development CLI
ampagent Jul 24, 2026
1b94ad3
Report transient containers as presence-only instead of omitting them
arjunkomath Jul 24, 2026
dd063c6
Merge pull request #202 from techulus/fix/cli-dev-config-directory
arjunkomath Jul 24, 2026
df568c5
Map settled podman states to stopped instead of transient
arjunkomath Jul 24, 2026
9e63f88
Merge pull request #201 from techulus/fix/transient-container-state-race
arjunkomath Jul 24, 2026
3afbc71
Show commit details in rollout history
techulus-agent Jul 24, 2026
1565e19
Merge pull request #203 from techulus/feat/rollout-commit-info
arjunkomath Jul 24, 2026
d24b031
Simplify routing state and status classification
arjunkomath Jul 24, 2026
8c2ef69
Merge pull request #204 from techulus/refactor/code-simplification
arjunkomath Jul 24, 2026
5ed1ae8
refactor Victoria Logs query transport
ampagent Jul 24, 2026
6782d22
refactor(agent): simplify signed JSON requests
ampagent Jul 24, 2026
2a71f5f
Restore AGENT.md agent guide
arjunkomath Jul 24, 2026
b445a9d
Merge pull request #207 from techulus/docs/agent-guide
arjunkomath Jul 24, 2026
1122189
preserve Victoria Logs timeout behavior
ampagent Jul 24, 2026
b28fdf6
test(agent): tighten HTTP client coverage
ampagent Jul 24, 2026
3fd8f65
simplify Victoria Logs pagination
ampagent Jul 24, 2026
495d57a
Merge pull request #206 from techulus/refactor/agent-http-client
arjunkomath Jul 25, 2026
6bf1010
Merge pull request #205 from techulus/refactor/victoria-logs-transport
arjunkomath Jul 25, 2026
b3322a7
Configure Amp orb lifecycle
ampagent Jul 25, 2026
a89fd72
Remove orb database provisioning
ampagent Jul 25, 2026
b48512e
Merge pull request #208 from techulus/chore/orb-setup
arjunkomath Jul 25, 2026
4edd6fc
Fix manual rollout completion
arjunkomath Jul 25, 2026
478ed4a
Merge pull request #209 from techulus/fix/empty-service-update-on-rol…
arjunkomath Jul 25, 2026
df16f16
Use mono font for cluster health metrics
ampagent Jul 25, 2026
f1e8593
Merge pull request #210 from techulus/ui/cluster-health-mono-metrics
arjunkomath Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/resume
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

echo "No persistent services require repair."
52 changes: 52 additions & 0 deletions .agents/setup
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mise_bin="$HOME/.local/bin/mise"

echo "Installing mise..."
if [[ ! -x "$mise_bin" ]]; then
curl -fsSL https://mise.run | sh
fi

profile_marker="# Techulus Cloud toolchains managed by mise"
if ! grep -Fqx "$profile_marker" "$HOME/.bash_profile" 2>/dev/null; then
cat >> "$HOME/.bash_profile" <<'EOF'

# Techulus Cloud toolchains managed by mise
if [[ -x "$HOME/.local/bin/mise" ]]; then
eval "$("$HOME/.local/bin/mise" activate bash)"
fi
EOF
fi

go_version="$(awk '$1 == "go" { print $2; exit }' "$repo_root/agent/go.mod")"

echo "Installing repository toolchains..."
"$mise_bin" use --global "go@$go_version" node@24 pnpm@11
for config in \
"$repo_root/agent/mise.toml" \
"$repo_root/web/mise.toml" \
"$repo_root/docs/mise.toml"; do
"$mise_bin" trust "$config"
(
cd "$(dirname "$config")"
"$mise_bin" install
)
done

echo "Installing web dependencies..."
(
cd "$repo_root/web"
"$mise_bin" exec -- pnpm install --frozen-lockfile
)

echo "Downloading Go dependencies..."
for module in agent cli deployment/updater; do
(
cd "$repo_root/$module"
"$mise_bin" exec -- go mod download
)
done

echo "Orb setup complete."
43 changes: 43 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Techulus Cloud — Agent Guide

An open container deployment platform. See README.md for architecture.

## Project facts

- **Unreleased beta.** Never build
backward-compatibility shims, deprecation windows, or migration paths for
old agent or API versions — delete and replace outright.
- The control plane and agent ship together; cross-cutting protocol changes
land in one PR with no rollout ordering concerns.

## Repo map

- `web/` — Next.js control plane (PostgreSQL + Drizzle, Inngest workflows)
- `agent/` — Go server agent (Podman, Traefik, WireGuard)
- `cli/` — Go CLI
- `deployment/` — production Compose files and updater
- `proxy/`, `registry/`, `logging/` — supporting service configs
- `docs/` — documentation

## Commands

- Web tests: `cd web && pnpm test`
- Web typecheck: `cd web && ./node_modules/.bin/tsc --noEmit`
- Web lint/format: `cd web && npx biome check --write <files>`
- Go (agent/cli): `go build ./...`, `go test ./...`, `gofmt -l .`
- After deleting or renaming a Next.js route, stale generated types in
`web/.next/types` can fail the typecheck — delete them; they regenerate.

## Making changes

- Pull latest main before starting; if there are conflicts, STOP.
- If product or architectural intent is unclear, ask — don't guess.
- Create a branch before committing; never commit to main or a release branch.
- Tests are expensive to write and maintain. Only add or expand tests for
high-value critical behavior, serious regression risk, or contracts that
would be costly to break. Keep tests focused; avoid low-signal harnesses.

## ⚠️ Critical restrictions

- **NEVER run the Node application** (`next dev`, `next start`, `pnpm dev`), Go Agent or Go CLI
without explicit permission. Tests, typechecks, and `go build` are fine.
1 change: 1 addition & 0 deletions CLAUDE.md
8 changes: 2 additions & 6 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,9 @@ curl -sSL $CONTROL_PLANE_URL/setup.sh | sudo bash

## Updating

To update an existing agent to the latest version:
When an update is available, open the server in the control plane and click **Update**. After you confirm the target version, the control plane queues an upgrade work item over the authenticated agent channel.

```bash
curl -sSL https://your-control-plane.com/update.sh | sudo bash
```

This downloads the latest agent binary, verifies the checksum, and restarts the service.
The agent downloads the release binary, verifies its checksum, installs it, and restarts itself.

## Manual Setup

Expand Down
3 changes: 3 additions & 0 deletions agent/internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type Agent struct {
pendingServerlessWake map[string]serverlessTransitionGuard
expectedStateMutex sync.RWMutex
latestExpectedState *agenthttp.ExpectedState
compiledTraefikMutex sync.Mutex
compiledTraefikFor *agenthttp.ExpectedState
compiledTraefik *compiledTraefikState
Client *agenthttp.Client
Reconciler *reconcile.Reconciler
Config *Config
Expand Down
65 changes: 18 additions & 47 deletions agent/internal/agent/drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,28 +353,19 @@ func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualS
}

if a.IsProxy {
expectedHttpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes)
expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, expected.ServerName)
tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes)
udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes)
expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes)
expectedCerts := make([]traefik.Certificate, len(expected.Traefik.Certificates))
for i, c := range expected.Traefik.Certificates {
expectedCerts[i] = traefik.Certificate{Domain: c.Domain, Certificate: c.Certificate, CertificateKey: c.CertificateKey}
}
expectedCertsHash := traefik.HashCertificates(expectedCerts)
if expectedTraefikHash != actual.TraefikConfigHash ||
expectedL4Hash != actual.L4ConfigHash ||
expectedCertsHash != actual.CertificatesHash ||
compiled := a.compiledTraefikState(expected)
if compiled.HTTPHash != actual.TraefikConfigHash ||
compiled.L4Hash != actual.L4ConfigHash ||
compiled.CertHash != actual.CertificatesHash ||
!actual.TraefikReloaded {
actions = append(actions, reconcileAction{
Kind: actionUpdateTraefik,
Description: fmt.Sprintf(
"UPDATE Traefik (%d HTTP, %d TCP, %d UDP routes; %d certificates)",
len(expected.Traefik.HttpRoutes),
len(tcpRoutes),
len(udpRoutes),
len(expected.Traefik.Certificates),
len(compiled.HTTP),
len(compiled.TCP),
len(compiled.UDP),
len(compiled.Certificates),
),
})
}
Expand Down Expand Up @@ -570,17 +561,7 @@ func (a *Agent) applyReconcileAction(action reconcileAction) error {
}

func (a *Agent) updateTraefik() error {
expectedHttpRoutes := ConvertToHttpRoutes(a.expectedState.Traefik.HttpRoutes)
tcpRoutes := ConvertToTCPRoutes(a.expectedState.Traefik.TCPRoutes)
udpRoutes := ConvertToUDPRoutes(a.expectedState.Traefik.UDPRoutes)

var tcpPorts, udpPorts []int
for _, r := range tcpRoutes {
tcpPorts = append(tcpPorts, r.ExternalPort)
}
for _, r := range udpRoutes {
udpPorts = append(udpPorts, r.ExternalPort)
}
compiled := a.compiledTraefikState(a.expectedState)

needsRestart := false
metricsRestart, err := traefik.EnsureMetricsConfig()
Expand All @@ -589,9 +570,9 @@ func (a *Agent) updateTraefik() error {
}
needsRestart = metricsRestart

if len(tcpPorts) > 0 || len(udpPorts) > 0 {
log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts))
entryPointsRestart, err := traefik.EnsureEntryPoints(tcpPorts, udpPorts)
if len(compiled.TCPPorts) > 0 || len(compiled.UDPPorts) > 0 {
log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(compiled.TCPPorts), len(compiled.UDPPorts))
entryPointsRestart, err := traefik.EnsureEntryPoints(compiled.TCPPorts, compiled.UDPPorts)
if err != nil {
return fmt.Errorf("failed to ensure entry points: %w", err)
}
Expand All @@ -604,19 +585,9 @@ func (a *Agent) updateTraefik() error {
}
}

expectedCerts := make([]traefik.Certificate, len(a.expectedState.Traefik.Certificates))
for i, certificate := range a.expectedState.Traefik.Certificates {
expectedCerts[i] = traefik.Certificate{
Domain: certificate.Domain,
Certificate: certificate.Certificate,
CertificateKey: certificate.CertificateKey,
}
}
expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, a.expectedState.ServerName)
expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes)
routesChanged := expectedTraefikHash != traefik.GetCurrentConfigHash() ||
expectedL4Hash != traefik.GetCurrentL4ConfigHash()
certificatesChanged := traefik.HashCertificates(expectedCerts) != traefik.GetCurrentCertificatesHash()
routesChanged := compiled.HTTPHash != traefik.GetCurrentConfigHash() ||
compiled.L4Hash != traefik.GetCurrentL4ConfigHash()
certificatesChanged := compiled.CertHash != traefik.GetCurrentCertificatesHash()
if !routesChanged && !certificatesChanged {
if err := traefik.EnsureDynamicConfigReloaded(a.DataDir, 15*time.Second); err != nil {
return fmt.Errorf("failed to recover Traefik config reload: %w", err)
Expand All @@ -633,13 +604,13 @@ func (a *Agent) updateTraefik() error {
}

if certificatesChanged {
if err := traefik.UpdateCertificates(expectedCerts); err != nil {
if err := traefik.UpdateCertificates(compiled.Certificates); err != nil {
return fmt.Errorf("failed to update Traefik certificates: %w", err)
}
}
if routesChanged {
log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes))
if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil {
log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(compiled.HTTP), len(compiled.TCP), len(compiled.UDP))
if err := traefik.UpdateHttpRoutesWithL4(compiled.HTTP, compiled.TCP, compiled.UDP, a.expectedState.ServerName); err != nil {
return fmt.Errorf("failed to update Traefik: %w", err)
}
}
Expand Down
59 changes: 59 additions & 0 deletions agent/internal/agent/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,62 @@ func ConvertToUDPRoutes(routes []agenthttp.TraefikUDPRoute) []traefik.TraefikUDP
}
return udpRoutes
}

type compiledTraefikState struct {
HTTP []traefik.TraefikRoute
TCP []traefik.TraefikTCPRoute
UDP []traefik.TraefikUDPRoute
Certificates []traefik.Certificate
TCPPorts []int
UDPPorts []int

HTTPHash string
L4Hash string
CertHash string
}

func compileTraefikState(expected *agenthttp.ExpectedState) *compiledTraefikState {
httpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes)
tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes)
udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes)

certificates := make([]traefik.Certificate, len(expected.Traefik.Certificates))
for i, c := range expected.Traefik.Certificates {
certificates[i] = traefik.Certificate{
Domain: c.Domain,
Certificate: c.Certificate,
CertificateKey: c.CertificateKey,
}
}

var tcpPorts, udpPorts []int
for _, r := range tcpRoutes {
tcpPorts = append(tcpPorts, r.ExternalPort)
}
for _, r := range udpRoutes {
udpPorts = append(udpPorts, r.ExternalPort)
}

return &compiledTraefikState{
HTTP: httpRoutes,
TCP: tcpRoutes,
UDP: udpRoutes,
Certificates: certificates,
TCPPorts: tcpPorts,
UDPPorts: udpPorts,
HTTPHash: traefik.HashRoutesWithServerName(httpRoutes, expected.ServerName),
L4Hash: traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes),
CertHash: traefik.HashCertificates(certificates),
}
}

func (a *Agent) compiledTraefikState(expected *agenthttp.ExpectedState) *compiledTraefikState {
a.compiledTraefikMutex.Lock()
defer a.compiledTraefikMutex.Unlock()

if a.compiledTraefikFor != expected || a.compiledTraefik == nil {
a.compiledTraefik = compileTraefikState(expected)
a.compiledTraefikFor = expected
}
return a.compiledTraefik
}
41 changes: 23 additions & 18 deletions agent/internal/agent/reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,26 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
if a.ShouldSuppressServerlessContainerReport(c.DeploymentID) {
continue
}

status := "stopped"
if c.State == "running" {
// Intermediate podman states (e.g. "created" mid-deploy) must not be
// reported as stopped — the control plane would move the deployment
// into a stopped phase — nor omitted, which would read as the
// container being gone. They are reported as "transient" so the
// control plane keeps tracking the deployment without acting until
// the state settles. Settled non-running states ("stopped",
// "paused") map to stopped so the deployment leaves routing and
// drift reconciliation can repair it; the same goes for "unknown"
// or unrecognized states, since presence-only reporting there would
// leave a broken container marked healthy indefinitely.
var status string
switch c.State {
case "running":
status = "running"
} else if c.State == "exited" {
case "exited", "stopped", "paused":
status = "stopped"
case "created", "configured", "initialized", "stopping", "removing":
status = "transient"
default:
log.Printf("[status] container %s in unexpected state %q, reporting as stopped", c.ID, c.State)
status = "stopped"
}

Expand Down Expand Up @@ -148,24 +163,14 @@ func (a *Agent) routingSyncedRolloutIds() []string {
}

func (a *Agent) proxyRoutingStateConverged(expected *agenthttp.ExpectedState) bool {
httpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes)
if traefik.HashRoutesWithServerName(httpRoutes, expected.ServerName) != traefik.GetCurrentConfigHash() {
compiled := a.compiledTraefikState(expected)
if compiled.HTTPHash != traefik.GetCurrentConfigHash() {
return false
}
tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes)
udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes)
if traefik.HashTCPRoutes(tcpRoutes)+traefik.HashUDPRoutes(udpRoutes) != traefik.GetCurrentL4ConfigHash() {
if compiled.L4Hash != traefik.GetCurrentL4ConfigHash() {
return false
}
certificates := make([]traefik.Certificate, len(expected.Traefik.Certificates))
for i, certificate := range expected.Traefik.Certificates {
certificates[i] = traefik.Certificate{
Domain: certificate.Domain,
Certificate: certificate.Certificate,
CertificateKey: certificate.CertificateKey,
}
}
if traefik.HashCertificates(certificates) != traefik.GetCurrentCertificatesHash() {
if compiled.CertHash != traefik.GetCurrentCertificatesHash() {
return false
}
reloaded, err := traefik.DynamicConfigReloaded(a.DataDir)
Expand Down
24 changes: 10 additions & 14 deletions agent/internal/agent/serverless.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,18 @@ func (a *Agent) SnapshotServerlessTransitions() []agenthttp.ServerlessTransition
return append([]agenthttp.ServerlessTransition(nil), a.pendingServerlessTransitions...)
}

func (a *Agent) ClearReportedServerlessTransitions(count int) {
if count <= 0 {
return
}

a.serverlessMutex.Lock()
defer a.serverlessMutex.Unlock()
if count > len(a.pendingServerlessTransitions) {
count = len(a.pendingServerlessTransitions)
}
a.pendingServerlessTransitions = a.pendingServerlessTransitions[count:]
}

func (a *Agent) AcknowledgeServerlessTransitions(results []agenthttp.ServerlessTransitionResult, reportedCount int) {
if len(results) == 0 {
a.ClearReportedServerlessTransitions(reportedCount)
if reportedCount <= 0 {
return
}

a.serverlessMutex.Lock()
defer a.serverlessMutex.Unlock()
if reportedCount > len(a.pendingServerlessTransitions) {
reportedCount = len(a.pendingServerlessTransitions)
}
a.pendingServerlessTransitions = a.pendingServerlessTransitions[reportedCount:]
return
}

Expand Down
Loading
Loading