Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4886c2a
docs: forbid agents from merging pull requests
ampagent Jul 30, 2026
071537e
Merge pull request #241 from techulus/docs/forbid-pr-merges
arjunkomath Jul 30, 2026
0828259
fix(deployment): make registry URL configurable
ampagent Jul 30, 2026
12321e4
Merge pull request #243 from techulus/fix/deployment-registry-url
arjunkomath Jul 30, 2026
eeae982
feat(dashboard): compact mobile layout for projects and servers
arjunkomath Jul 30, 2026
70bad66
Merge pull request #244 from techulus/feat/compact-mobile-dashboard
arjunkomath Jul 30, 2026
9da2268
docs: add spec-driven development workflow
ampagent Jul 31, 2026
5cae421
Merge pull request #245 from techulus/docs/spec-driven-agent-workflow
arjunkomath Jul 31, 2026
3b37a72
Reduce service graph metric font size
ampagent Jul 31, 2026
9b03333
Add Techulus host response header
ampagent Jul 31, 2026
15c2cc8
Merge pull request #246 from techulus/fix/reduce-graph-metric-font-size
arjunkomath Jul 31, 2026
3b03cd9
Merge pull request #247 from techulus/feat/traefik-techulus-host-header
arjunkomath Jul 31, 2026
56e5289
Add dashboard command menu navigation
ampagent Jul 31, 2026
5c7857d
Improve command menu navigation styles
ampagent Jul 31, 2026
5a4d135
Rank command search results globally
ampagent Jul 31, 2026
34bc80d
Merge pull request #248 from techulus/feature/dashboard-command-menu
arjunkomath Jul 31, 2026
60cef34
Add automatic canvas layout
ampagent Jul 31, 2026
bd6657c
Address canvas layout review
ampagent Jul 31, 2026
d40c865
Merge pull request #249 from techulus/feat/auto-layout-canvas
arjunkomath Jul 31, 2026
ca81784
fix: constrain serverless to HTTP services
ampagent Jul 31, 2026
8bb5ee3
Authorize restore completion through work leases
ampagent Jul 31, 2026
a6aab1d
Merge pull request #251 from techulus/fix/authorize-restore-completion
arjunkomath Jul 31, 2026
7df74b8
Merge pull request #250 from techulus/fix/serverless-http-visibility
arjunkomath Jul 31, 2026
9f4893d
Fix impure command menu state updater
ampagent Jul 31, 2026
89591bd
Merge pull request #252 from techulus/fix/react-doctor-impure-state-u…
arjunkomath Jul 31, 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
69 changes: 67 additions & 2 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,77 @@ An open container deployment platform. See README.md for architecture.
high-value critical behavior, serious regression risk, or contracts that
would be costly to break. Keep tests focused; avoid low-signal harnesses.

## Spec-driven development workflow

For any requested code or configuration change, work through these phases in
order. Do not collapse requirements, specification, and implementation
planning into a single step.

### 1. Research and refine requirements

Understand the problem before designing a solution.

- Inspect the relevant code, documentation, existing behavior, and project
constraints.
- Clarify the desired outcome, scope, non-goals, edge cases, and acceptance
criteria.
- Identify assumptions and ask focused questions when ambiguity would
materially affect the solution.
- Present the refined requirements for confirmation.

Deliverable: agreed requirements, constraints, assumptions, and acceptance
criteria.

### 2. Build the specification

Describe what will be built and how it should work.

- Define user-visible and system behavior.
- Describe the technical approach, architecture, interfaces, data flow, and
error handling.
- Address important edge cases and consequential tradeoffs.
- Keep the specification solution-level rather than file-by-file.

Deliverable: a reviewable specification of the intended behavior and technical
design.

### 3. Create the development plan

Translate the specification into concrete implementation work.

- List the files and modules that will be added, changed, renamed, or removed.
- Describe the specific changes required in each location.
- Include API, schema, type, dependency, and configuration changes where
applicable.
- Define the tests and verification commands that will be run.
- Order the work into small, reviewable steps and identify remaining risks.

Deliverable: an actionable, file-level development plan.

### 4. Implement after approval

Do not modify the codebase until the user approves the development plan.

- Implement the approved plan using the smallest correct changes and existing
project patterns.
- Run the planned verification and report the results honestly.
- If new information requires a material change to the requirements,
specification, scope, or architecture, pause implementation and return to
the appropriate phase for approval.
- Resolve minor implementation details autonomously when they do not alter the
approved behavior or scope.

Deliverable: implemented changes, verification results, and a concise summary
of any deviations or limitations.

## Communication

- Keep responses concise and to the point. Avoid verbose responses unless
explicitly asked.
- Keep all written content as short as possible without omitting necessary
detail. Expand only when explicitly asked.

## ⚠️ Critical restrictions

- **NEVER EVER merge a pull request.** This prohibition is absolute, even if
the pull request is approved, checks pass, or the user asks you to ship it.
- **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.
37 changes: 13 additions & 24 deletions agent/internal/agent/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,56 +154,49 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam
volumePath := filepath.Join(a.DataDir, "volumes", serviceID, volumeName)
log.Printf("[restore_volume] restoring volume %s to %s", volumeName, volumePath)

reportFailure := func(err error) error {
if reportErr := a.Client.ReportRestoreComplete(backupID, false, err.Error()); reportErr != nil {
log.Printf("[restore_volume] warning: failed to report restore failure: %v", reportErr)
}
return err
}

tarPath, err := tempArtifactPath(a.DataDir, fmt.Sprintf("restore-%s.tar.gz", backupID))
if err != nil {
return reportFailure(fmt.Errorf("failed to create temp archive path: %w", err))
return fmt.Errorf("failed to create temp archive path: %w", err)
}
defer os.Remove(tarPath)

if !strings.HasSuffix(storagePath, ".tar.gz") {
return reportFailure(fmt.Errorf("unsupported backup archive path: %s", storagePath))
return fmt.Errorf("unsupported backup archive path: %s", storagePath)
}

s3Client, err := createS3Client(storageConfig)
if err != nil {
return reportFailure(fmt.Errorf("failed to create S3 client: %w", err))
return fmt.Errorf("failed to create S3 client: %w", err)
}

if err := downloadFromS3(s3Client, storageConfig.Bucket, storagePath, tarPath); err != nil {
return reportFailure(fmt.Errorf("failed to download from S3: %w", err))
return fmt.Errorf("failed to download from S3: %w", err)
}

log.Printf("[restore_volume] downloaded from S3: %s/%s", storageConfig.Bucket, storagePath)

checksum, err := calculateChecksum(tarPath)
if err != nil {
return reportFailure(fmt.Errorf("failed to calculate checksum: %w", err))
return fmt.Errorf("failed to calculate checksum: %w", err)
}

if checksum != expectedChecksum {
return reportFailure(fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, checksum))
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, checksum)
}

tempExtractPath, err := tempArtifactPath(a.DataDir, fmt.Sprintf("restore-extract-%s", backupID))
if err != nil {
return reportFailure(fmt.Errorf("failed to create temp extract path: %w", err))
return fmt.Errorf("failed to create temp extract path: %w", err)
}
defer os.RemoveAll(tempExtractPath)

if err := os.MkdirAll(tempExtractPath, 0755); err != nil {
return reportFailure(fmt.Errorf("failed to create temp extract directory: %w", err))
return fmt.Errorf("failed to create temp extract directory: %w", err)
}

log.Printf("[restore_volume] extracting archive to temp location for validation")
if err := extractTarGz(tarPath, tempExtractPath); err != nil {
return reportFailure(fmt.Errorf("failed to extract archive: %w", err))
return fmt.Errorf("failed to extract archive: %w", err)
}

var shouldStartContainer bool
Expand All @@ -214,7 +207,7 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam
} else if running {
log.Printf("[restore_volume] stopping container %s before restore", Truncate(containerID, 12))
if err := container.Stop(containerID); err != nil {
return reportFailure(fmt.Errorf("failed to stop container: %w", err))
return fmt.Errorf("failed to stop container: %w", err)
}
shouldStartContainer = true
} else {
Expand Down Expand Up @@ -243,27 +236,23 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam

if err := os.RemoveAll(volumePath); err != nil && !os.IsNotExist(err) {
startContainerWithRetry()
return reportFailure(fmt.Errorf("failed to remove existing volume: %w", err))
return fmt.Errorf("failed to remove existing volume: %w", err)
}

if err := os.MkdirAll(filepath.Dir(volumePath), 0755); err != nil {
startContainerWithRetry()
return reportFailure(fmt.Errorf("failed to create volume parent directory: %w", err))
return fmt.Errorf("failed to create volume parent directory: %w", err)
}

if err := moveDir(tempExtractPath, volumePath); err != nil {
startContainerWithRetry()
return reportFailure(fmt.Errorf("failed to move restored data to volume path: %w", err))
return fmt.Errorf("failed to move restored data to volume path: %w", err)
}

startContainerWithRetry()

log.Printf("[restore_volume] restored volume %s successfully", volumeName)

if err := a.Client.ReportRestoreComplete(backupID, true, ""); err != nil {
log.Printf("[restore_volume] warning: failed to report restore complete: %v", err)
}

return nil
}

Expand Down
23 changes: 0 additions & 23 deletions agent/internal/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,26 +599,3 @@ func (c *Client) ReportBackupFailed(backupID string, errorMsg string) error {

return nil
}

func (c *Client) ReportRestoreComplete(backupID string, success bool, errorMsg string) error {
payload := map[string]interface{}{
"backupId": backupID,
"success": success,
}
if errorMsg != "" {
payload["error"] = errorMsg
}

body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal restore complete: %w", err)
}

resp, err := c.doSignedJSONRequest(c.baseURL+"/api/v1/agent/restore/complete", body, []int{http.StatusOK}, "failed to report restore complete", "restore complete report failed")
if err != nil {
return err
}
defer resp.Body.Close()

return nil
}
5 changes: 4 additions & 1 deletion agent/internal/traefik/l4.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ func CompileRoutes(httpRoutes []TraefikRoute, tcpRoutes []TraefikTCPRoute, udpRo
}
var middlewareNames []string
if serverName != "" {
config.HTTP.Middlewares["forwarded_server"] = middleware{Headers: &headersMiddleware{CustomRequestHeaders: map[string]string{"X-Forwarded-Server": serverName}}}
config.HTTP.Middlewares["forwarded_server"] = middleware{Headers: &headersMiddleware{
CustomRequestHeaders: map[string]string{"X-Forwarded-Server": serverName},
CustomResponseHeaders: map[string]string{"X-Techulus-Host": serverName},
}}
middlewareNames = []string{"forwarded_server@file"}
}
for _, route := range httpRoutes {
Expand Down
3 changes: 3 additions & 0 deletions agent/internal/traefik/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func TestRoutesConfigRoundTripProducesConvergentHash(t *testing.T) {
if forwardedServer.Headers == nil || forwardedServer.Headers.CustomRequestHeaders["X-Forwarded-Server"] != "proxy-1" {
t.Fatalf("forwarded server middleware was not preserved: %#v", forwardedServer)
}
if forwardedServer.Headers.CustomResponseHeaders["X-Techulus-Host"] != "proxy-1" {
t.Fatalf("Techulus host response header was not preserved: %#v", forwardedServer)
}
tcpRouter := config.TCP.Routers[resourceName("tcp", "service-tcp", "tcp-route")]
if tcpRouter.TLS == nil || !tcpRouter.TLS.Passthrough {
t.Fatalf("TCP TLS passthrough was not preserved: %#v", tcpRouter)
Expand Down
3 changes: 2 additions & 1 deletion agent/internal/traefik/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ type middleware struct {
}

type headersMiddleware struct {
CustomRequestHeaders map[string]string `yaml:"customRequestHeaders,omitempty"`
CustomRequestHeaders map[string]string `yaml:"customRequestHeaders,omitempty"`
CustomResponseHeaders map[string]string `yaml:"customResponseHeaders,omitempty"`
}

type replacePathRegex struct {
Expand Down
1 change: 1 addition & 0 deletions deployment/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ VM_PASSWORD=your-secure-metrics-password
VM_RETENTION=30d

# Registry
REGISTRY_URL=registry:5000
REGISTRY_USERNAME=admin
REGISTRY_PASSWORD=your-registry-password
REGISTRY_HTTP_SECRET=your-registry-http-secret
Expand Down
3 changes: 1 addition & 2 deletions deployment/compose.postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ services:
- VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428
- VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN}
- VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428
- REGISTRY_URL=registry:5000
- REGISTRY_HOST=registry.${ROOT_DOMAIN}
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
Expand Down Expand Up @@ -110,7 +109,7 @@ services:
- VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428
- VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN}
- VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428
- REGISTRY_URL=registry:5000
- REGISTRY_URL=${REGISTRY_URL:-registry:5000}
- REGISTRY_HOST=registry.${ROOT_DOMAIN}
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
Expand Down
3 changes: 1 addition & 2 deletions deployment/compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ services:
- VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428
- VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN}
- VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428
- REGISTRY_URL=registry:5000
- REGISTRY_HOST=registry.${ROOT_DOMAIN}
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
Expand All @@ -89,7 +88,7 @@ services:
- VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428
- VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN}
- VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428
- REGISTRY_URL=registry:5000
- REGISTRY_URL=${REGISTRY_URL:-registry:5000}
- REGISTRY_HOST=registry.${ROOT_DOMAIN}
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
Expand Down
1 change: 1 addition & 0 deletions deployment/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ VM_USERNAME=${VM_USERNAME}
VM_PASSWORD=${VM_PASSWORD}
VM_RETENTION=30d

REGISTRY_URL=registry:5000
REGISTRY_USERNAME=${REGISTRY_USERNAME}
REGISTRY_PASSWORD=${REGISTRY_PASSWORD}
REGISTRY_HTTP_SECRET=${REGISTRY_HTTP_SECRET}
Expand Down
4 changes: 3 additions & 1 deletion docs/api/public-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ Configuration and revision responses never include secret names, values, or ciph

`PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, and `resources`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`.

Replacing the final public HTTP port with a domain automatically disables serverless in the mutable service configuration. The plan reports this side effect as a `serverless.enabled` change before apply. You do not send serverless settings in the replacement request.

```json
{
"name": "web",
Expand Down Expand Up @@ -215,7 +217,7 @@ Use manual placement to choose exact servers:
}
```

Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field.
Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. A replacement that removes the final public HTTP domain can move the service to worker placement because the same update disables serverless. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field.

The API only manages stateless services with HTTP ports. Existing volumes, stateful mode, TCP or UDP ports, TLS passthrough, or invalid resource limits return a conflict with an actionable code.

Expand Down
4 changes: 4 additions & 0 deletions docs/services/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Serverless settings are configured per service:
| Sleep after | `300s` | Idle period before running containers are stopped |
| Wake timeout | `300s` | Maximum time the wake gateway waits for ready upstreams |

The serverless settings appear only when the service has a public HTTP port with
a domain. Removing the final qualifying endpoint disables serverless in the
pending service configuration.

A cold wake starts the sleeping local proxy replicas for that host. Held
requests resume when one upstream is ready.

Expand Down
11 changes: 11 additions & 0 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,17 @@ export async function updateServiceConfig(
})),
);
if (issue) throw new Error(issue.message);
if (
!finalPorts.some(
(port) =>
port.isPublic && port.protocol === "http" && port.domain !== null,
)
) {
await tx
.update(services)
.set({ serverlessEnabled: false })
.where(eq(services.id, serviceId));
}

for (const port of additions) {
if (!port.domain) continue;
Expand Down
Loading
Loading