feat(sessions): add command execution endpoint and activity tracking
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add POST /sessions/:id/exec endpoint for executing commands in sessions - Add session activity tracking (last_activity_at timestamp) - Add database migration 024 for session activity column - Add comprehensive tests for session handlers and service layer - Add wildcard TLS certificate for preview.threesix.ai subdomain - Add infrastructure mocks for testing preview service - Refactor preview cleanup logic to remove unused methods - Add AIOS core documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b6ddcd92d2
commit
7249575dea
@ -544,7 +544,7 @@ func main() {
|
|||||||
// Initialize sessions handler (for interactive remote development)
|
// Initialize sessions handler (for interactive remote development)
|
||||||
var sessionsHandler *handlers.SessionsHandler
|
var sessionsHandler *handlers.SessionsHandler
|
||||||
if sessionService != nil {
|
if sessionService != nil {
|
||||||
sessionsHandler = handlers.NewSessionsHandler(sessionService)
|
sessionsHandler = handlers.NewSessionsHandler(sessionService, k8sExecutor, streamPub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize saga system (resilient workflow orchestration)
|
// Initialize saga system (resilient workflow orchestration)
|
||||||
|
|||||||
@ -33,3 +33,6 @@ resources:
|
|||||||
- pdb.yaml
|
- pdb.yaml
|
||||||
- network-policy.yaml
|
- network-policy.yaml
|
||||||
|
|
||||||
|
# Wildcard TLS for session preview URLs
|
||||||
|
- preview-cert.yaml
|
||||||
|
|
||||||
|
|||||||
12
deployments/k8s/base/preview-cert.yaml
Normal file
12
deployments/k8s/base/preview-cert.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: preview-wildcard-tls
|
||||||
|
namespace: rdev
|
||||||
|
spec:
|
||||||
|
secretName: preview-wildcard-tls
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
kind: ClusterIssuer
|
||||||
|
dnsNames:
|
||||||
|
- "*.preview.threesix.ai"
|
||||||
282
docs/aios-core/info.md
Normal file
282
docs/aios-core/info.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# Synkra AIOS-Core Analysis
|
||||||
|
|
||||||
|
> Repository: [github.com/SynkraAI/aios-core](https://github.com/SynkraAI/aios-core)
|
||||||
|
> Version: 4.0.4 | Language: JavaScript (ES2022, CommonJS) | Runtime: Node.js 18+
|
||||||
|
|
||||||
|
## What Is It?
|
||||||
|
|
||||||
|
**AIOS (AI-Orchestrated System)** is a CLI framework that orchestrates specialized AI agents to collaboratively build software using structured methodologies. It eliminates two critical problems in LLM-assisted development: **planning inconsistency** (solved via hierarchical agent roles) and **context loss** (solved via a deterministic 8-layer context injection engine called SYNAPSE).
|
||||||
|
|
||||||
|
It implements an **"Agentic Agile"** methodology: hierarchical planning (analyst -> PM -> architect) followed by contextual implementation (scrum master -> developer -> QA). Each agent has exclusive authority over its domain — only `@devops` can push, only `@architect` makes architecture decisions, only `@qa` issues quality verdicts.
|
||||||
|
|
||||||
|
**Target audience:** Teams using AI-assisted development who want structured, repeatable workflows with agent specialization rather than a single general-purpose LLM chat.
|
||||||
|
|
||||||
|
**Install:** `npx aios-core@latest init my-project` — drops a `.aios-core/` directory into your project and integrates with Claude Code, Cursor, or Windsurf.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI (bin/aios.js, Commander)
|
||||||
|
|
|
||||||
|
+-- Master Orchestrator (core/orchestration/)
|
||||||
|
| +-- Agent Invoker # Activate agents by @name
|
||||||
|
| +-- Workflow Executor # Multi-step orchestration
|
||||||
|
| +-- SYNAPSE Context Engine # 8-layer context injection (L0-L7)
|
||||||
|
| +-- Recovery Handler # Error rollback & retry
|
||||||
|
| +-- Session State Manager # Cross-session continuity
|
||||||
|
|
|
||||||
|
+-- Agent System (.aios-core/development/agents/)
|
||||||
|
| 12 agents, each with: persona, commands, dependencies, activation flow
|
||||||
|
|
|
||||||
|
+-- Task System (.aios-core/development/tasks/)
|
||||||
|
| 201 executable tasks as Markdown+YAML files
|
||||||
|
|
|
||||||
|
+-- Execution Engines (core/execution/)
|
||||||
|
| +-- Build Orchestrator # Autonomous code generation
|
||||||
|
| +-- Semantic Merge Engine # AST-aware code merging
|
||||||
|
| +-- Subagent Dispatcher # Parallel task execution
|
||||||
|
| +-- Wave Executor # Batched operations with deps
|
||||||
|
|
|
||||||
|
+-- Quality Gates (quality/, hooks/)
|
||||||
|
| Pre-commit, pre-push, CI validation
|
||||||
|
|
|
||||||
|
+-- Configuration (4-level hierarchy)
|
||||||
|
L1 Framework -> L2 Project -> L3 Core -> L4 Local (user)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
aios-core/
|
||||||
|
+-- .aios-core/ # Framework core (installed into projects)
|
||||||
|
| +-- core/ # Runtime engines (~25 modules)
|
||||||
|
| | +-- orchestration/ # Master orchestrator (16KB)
|
||||||
|
| | +-- execution/ # Build, merge, dispatch (12KB)
|
||||||
|
| | +-- synapse/ # 8-layer context injection
|
||||||
|
| | +-- config/ # Config loaders (L0-L4)
|
||||||
|
| | +-- ids/ # ID generators
|
||||||
|
| | +-- health-check/ # Diagnostics
|
||||||
|
| +-- development/ # Agent definitions & workflows
|
||||||
|
| | +-- agents/ # 12 agent definitions
|
||||||
|
| | +-- tasks/ # 201 executable tasks
|
||||||
|
| | +-- workflows/ # 15 workflow definitions
|
||||||
|
| | +-- templates/ # 9 document templates
|
||||||
|
| | +-- agent-teams/ # 7 bundled agent groups
|
||||||
|
| +-- infrastructure/ # Build scripts, CI templates
|
||||||
|
| +-- hooks/ # Git hooks
|
||||||
|
| +-- product/ # Squads, mind contexts
|
||||||
|
| +-- quality/ # Quality gate implementations
|
||||||
|
| +-- schemas/ # JSON Schema validators
|
||||||
|
| +-- constitution.md # Formal principles & gates
|
||||||
|
+-- bin/ # CLI executables
|
||||||
|
+-- packages/ # Monorepo (aios-install, gemini-ext)
|
||||||
|
+-- .claude/ # Claude Code integration
|
||||||
|
+-- docs/ # Documentation (~6.8MB)
|
||||||
|
+-- tests/ # Test suite (~4MB, 80%+ coverage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Modules by Size
|
||||||
|
|
||||||
|
| Module | LOC | Purpose |
|
||||||
|
|--------|-----|---------|
|
||||||
|
| master-orchestrator.js | 1,542 | Main orchestration engine |
|
||||||
|
| semantic-merge-engine.js | 1,180 | AST-aware code merging |
|
||||||
|
| workflow-executor.js | 1,180 | Multi-step workflow execution |
|
||||||
|
| terminal-spawner.js | 1,043 | Shell command execution with streaming |
|
||||||
|
| build-state-manager.js | 948 | Build execution state tracking |
|
||||||
|
| greenfield-handler.js | 888 | New project initialization |
|
||||||
|
| session-state.js | 875 | Cross-session continuity |
|
||||||
|
| recovery-handler.js | 720 | Error recovery & rollback |
|
||||||
|
|
||||||
|
## The 12 Agents
|
||||||
|
|
||||||
|
| Agent | Persona | Role | Exclusive Authority |
|
||||||
|
|-------|---------|------|---------------------|
|
||||||
|
| `@dev` | Dex (Builder) | Senior Developer | Code implementation |
|
||||||
|
| `@qa` | Quinn (Guardian) | Test Architect | Quality verdicts |
|
||||||
|
| `@architect` | Aria (Architect) | System Designer | Architecture decisions |
|
||||||
|
| `@pm` | Morgan (Analyzer) | Product Manager | Requirements & specs |
|
||||||
|
| `@po` | Pax (Curator) | Product Owner | Story creation & prioritization |
|
||||||
|
| `@sm` | River (Facilitator) | Scrum Master | Sprint planning & story flow |
|
||||||
|
| `@analyst` | Alex (Researcher) | Business Analyst | Research & pattern extraction |
|
||||||
|
| `@data-engineer` | Dara (Data Architect) | DB Designer | Schema design & migrations |
|
||||||
|
| `@ux-design-expert` | Uma (Designer) | UX/UI Specialist | Design & usability |
|
||||||
|
| `@devops` | Gage (Operator) | DevOps Engineer | git push, PRs, releases |
|
||||||
|
| `@aios-master` | Orion (Orchestrator) | Framework Developer | All capabilities |
|
||||||
|
| `@squad-creator` | Scout (Builder) | Expansion Creator | Build new squads/domains |
|
||||||
|
|
||||||
|
Agent authority is non-negotiable. Only `@devops` can `git push`. Only `@architect` makes architecture decisions. Enforced by the Constitution system.
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. npx aios-core init my-project
|
||||||
|
2. @pm: *gather-requirements -> *write-spec
|
||||||
|
3. Approve spec, activate @architect
|
||||||
|
4. @architect: *create-plan -> *map-codebase
|
||||||
|
5. @po: *shard-doc (split large docs) -> *create-next-story
|
||||||
|
6. @dev: *develop (implement story)
|
||||||
|
7. @qa: *review-story
|
||||||
|
8. @devops: *git-push
|
||||||
|
9. Loop to step 5 for next story
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10 Interesting Things
|
||||||
|
|
||||||
|
### 1. SYNAPSE: 8-Layer Deterministic Context Injection
|
||||||
|
|
||||||
|
The standout innovation. Rather than stuffing everything into one LLM prompt, SYNAPSE builds context through 8 prioritized layers:
|
||||||
|
|
||||||
|
| Layer | Name | What It Injects |
|
||||||
|
|-------|------|-----------------|
|
||||||
|
| L0 | Constitution | Non-negotiable principles (CLI-first, agent authority) |
|
||||||
|
| L1 | Global | Framework-wide coding standards |
|
||||||
|
| L2 | Agent | Agent-specific rules ("Dev only updates story Dev section") |
|
||||||
|
| L3 | Workflow | Current phase, expected next step |
|
||||||
|
| L4 | Task | Acceptance criteria, inputs/outputs |
|
||||||
|
| L5 | Squad | Domain-specific conventions |
|
||||||
|
| L6 | Keyword | Triggered by tags like `[LLM: complex]` |
|
||||||
|
| L7 | Star-Command | Command-specific context for `*develop`, `*review`, etc. |
|
||||||
|
|
||||||
|
Output is XML prepended to every LLM prompt:
|
||||||
|
```xml
|
||||||
|
<synapse-rules>
|
||||||
|
<layer level="0" name="constitution">
|
||||||
|
<rule>CLI First: All functionality works 100% via CLI</rule>
|
||||||
|
<rule>Agent Authority: Only @devops can git push</rule>
|
||||||
|
</layer>
|
||||||
|
<layer level="2" name="agent" agent="dev">
|
||||||
|
<rule>CRITICAL: ONLY update story Dev Agent Record</rule>
|
||||||
|
</layer>
|
||||||
|
</synapse-rules>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Markdown as Executable Specification
|
||||||
|
|
||||||
|
All 201 tasks are Markdown files with YAML front matter that serve as both documentation AND executable specs. The framework parses them into execution plans:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
task: create-next-story()
|
||||||
|
responsible: River (Scrum Master)
|
||||||
|
entrada:
|
||||||
|
- campo: epic_name
|
||||||
|
tipo: string
|
||||||
|
required: true
|
||||||
|
saida:
|
||||||
|
- campo: created_file
|
||||||
|
tipo: string
|
||||||
|
acceptance-criteria:
|
||||||
|
- [ ] Story has all required sections
|
||||||
|
---
|
||||||
|
## Execution Steps
|
||||||
|
1. Validate epic exists
|
||||||
|
2. Create story file
|
||||||
|
3. Update epic index
|
||||||
|
```
|
||||||
|
|
||||||
|
The Markdown tree parser extracts YAML metadata, numbered steps become execution plans, and checkboxes become progress tracking.
|
||||||
|
|
||||||
|
### 3. Formal Constitution with Automated Enforcement
|
||||||
|
|
||||||
|
A `constitution.md` file defines legally-styled principles with three severity levels enforced by quality gates:
|
||||||
|
|
||||||
|
- **NON-NEGOTIABLE**: Gates block execution (CLI-first, agent authority)
|
||||||
|
- **MUST**: Gates warn but allow (story-driven dev, quality-first)
|
||||||
|
- **SHOULD**: Gates inform only (absolute imports)
|
||||||
|
|
||||||
|
This is unusual — most projects have informal "contributing" docs. AIOS has a formal governance document with automated enforcement.
|
||||||
|
|
||||||
|
### 4. Semantic Merge Engine (AST-Aware)
|
||||||
|
|
||||||
|
Instead of line-based git merges, the semantic merge engine understands code structure:
|
||||||
|
- Function-level conflict detection
|
||||||
|
- Import reorganization
|
||||||
|
- 3-way merge (ours/theirs/base) with AST awareness
|
||||||
|
- Eliminates typical merge conflicts in multi-agent workflows where different agents modify the same files
|
||||||
|
|
||||||
|
### 5. Session State Machine with Recovery
|
||||||
|
|
||||||
|
Agent conversations survive IDE restarts. A `SessionState` tracks current agent, task, story, execution history, and checkpoints. The recovery handler can resume from the last good state after failures — critical for long-running multi-agent workflows that might span hours.
|
||||||
|
|
||||||
|
Persisted to `.aios/session-state.yaml`.
|
||||||
|
|
||||||
|
### 6. No Database — Git Repository IS the Database
|
||||||
|
|
||||||
|
Zero traditional database. All state lives in the filesystem:
|
||||||
|
- YAML for configuration
|
||||||
|
- Markdown for stories, tasks, specs
|
||||||
|
- JSON for metadata
|
||||||
|
- Git history for audit trail
|
||||||
|
|
||||||
|
The repository itself is the single source of truth. This makes the entire system portable and version-controlled by default.
|
||||||
|
|
||||||
|
### 7. Wave Executor for Parallel Agent Work
|
||||||
|
|
||||||
|
Independent tasks run in parallel, respecting dependency graphs:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const wave = [
|
||||||
|
{ task: 'task-a', deps: [] },
|
||||||
|
{ task: 'task-b', deps: [] }, // Parallel with A
|
||||||
|
{ task: 'task-c', deps: ['a', 'b'] } // Waits for A+B
|
||||||
|
];
|
||||||
|
await waveExecutor.execute(wave);
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables fast multi-agent workflows without race conditions.
|
||||||
|
|
||||||
|
### 8. Squads: Domain-Extensible Agent Teams
|
||||||
|
|
||||||
|
The framework isn't locked to software development. "Squads" are pluggable domain-specific agent teams with their own agents, tasks, templates, and knowledge bases. A squad for legal, marketing, healthcare, or education could define 1-5 specialized agents with 20-50 domain tasks.
|
||||||
|
|
||||||
|
Structure: `squads/your-squad/{config.yaml, agents/, tasks/, templates/, data/}`.
|
||||||
|
|
||||||
|
### 9. Three Execution Modes
|
||||||
|
|
||||||
|
Every task can run in one of three modes:
|
||||||
|
- **YOLO** (0-1 prompts): Fully autonomous with logging. Fast, dangerous.
|
||||||
|
- **Interactive** (5-10 prompts): Explicit checkpoints at key decision points. Default.
|
||||||
|
- **Pre-Flight** (comprehensive): Zero-ambiguity upfront planning before any execution.
|
||||||
|
|
||||||
|
This is a thoughtful UX choice — developers pick their risk tolerance per task.
|
||||||
|
|
||||||
|
### 10. Portuguese-First, Multi-Language
|
||||||
|
|
||||||
|
The codebase and task definitions use Portuguese for many field names (`responsavel`, `entrada`, `saida`, `pre-condicoes`). Documentation exists in Portuguese, English, and Spanish. This is unusual for an open-source framework and reflects its origin from Pedro Valerio's hybrid-ops methodology. The English docs are comprehensive but the Portuguese roots show the project's cultural DNA.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Runtime | Node.js 18+ |
|
||||||
|
| Language | JavaScript ES2022 (CommonJS, ESM migration planned) |
|
||||||
|
| Types | TypeScript 5.9.3 (definitions only, not compiled) |
|
||||||
|
| CLI | Commander 12.1.0 |
|
||||||
|
| Prompts | @clack/prompts 0.11.0 |
|
||||||
|
| Templates | Handlebars 4.7.8 |
|
||||||
|
| Validation | ajv (JSON Schema) + validator |
|
||||||
|
| YAML | js-yaml 4.1.0 |
|
||||||
|
| Markdown | @kayvan/markdown-tree-parser 1.5.0 |
|
||||||
|
| Process | execa 5.1.1 |
|
||||||
|
| Testing | Jest 30.2.0 (80%+ coverage required) |
|
||||||
|
| Linting | ESLint 9.38.0 + Prettier 3.5.3 |
|
||||||
|
| Release | semantic-release 25.0.2 |
|
||||||
|
| Git Hooks | husky 9.1.7 |
|
||||||
|
|
||||||
|
**19 production dependencies** — deliberately minimal for a framework of this scope.
|
||||||
|
|
||||||
|
## Scale
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Agents | 12 |
|
||||||
|
| Executable tasks | 201 |
|
||||||
|
| Workflows | 15 |
|
||||||
|
| Document templates | 9 |
|
||||||
|
| Agent teams | 7 |
|
||||||
|
| SYNAPSE layers | 8 |
|
||||||
|
| Config levels | 4 |
|
||||||
|
| Supported IDEs | 3 (Claude Code, Cursor, Windsurf) |
|
||||||
|
| Test coverage | 80%+ |
|
||||||
|
| Docs | ~6.8MB |
|
||||||
@ -3,7 +3,6 @@ package kubernetes
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
networkingv1 "k8s.io/api/networking/v1"
|
networkingv1 "k8s.io/api/networking/v1"
|
||||||
@ -59,10 +58,7 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := resourceName(opts.SessionID)
|
name := resourceName(opts.SessionID)
|
||||||
ns := opts.Namespace
|
ns := m.config.Namespace
|
||||||
if ns == "" {
|
|
||||||
ns = m.config.Namespace
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Service targeting the pod by its rdev project label.
|
// Create Service targeting the pod by its rdev project label.
|
||||||
// Project pods are labeled with rdev.orchard9.ai/name=<project-id>.
|
// Project pods are labeled with rdev.orchard9.ai/name=<project-id>.
|
||||||
@ -101,14 +97,15 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt
|
|||||||
|
|
||||||
// Create Ingress for TLS-terminated route.
|
// Create Ingress for TLS-terminated route.
|
||||||
pathType := networkingv1.PathTypePrefix
|
pathType := networkingv1.PathTypePrefix
|
||||||
tlsSecretName := strings.ReplaceAll(opts.Host, ".", "-") + "-tls"
|
|
||||||
|
|
||||||
annotations := map[string]string{}
|
// Use the shared wildcard TLS secret (preview-wildcard-tls) for all preview
|
||||||
if m.config.TLSIssuer != "" {
|
// ingresses. This avoids per-session cert-manager certificate requests.
|
||||||
annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer
|
tlsSecretName := "preview-wildcard-tls"
|
||||||
|
|
||||||
|
annotations := map[string]string{
|
||||||
|
"traefik.ingress.kubernetes.io/router.entrypoints": "websecure",
|
||||||
|
"traefik.ingress.kubernetes.io/router.tls": "true",
|
||||||
}
|
}
|
||||||
annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure"
|
|
||||||
annotations["traefik.ingress.kubernetes.io/router.tls"] = "true"
|
|
||||||
|
|
||||||
ingress := &networkingv1.Ingress{
|
ingress := &networkingv1.Ingress{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
|||||||
@ -30,9 +30,9 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session)
|
|||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO sessions (
|
INSERT INTO sessions (
|
||||||
project_id, checkout_id, pod_name, preview_url, preview_host,
|
project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||||
created_by, created_at, expires_at, status
|
created_by, created_at, expires_at, status, last_activity_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
string(session.ProjectID),
|
string(session.ProjectID),
|
||||||
@ -44,6 +44,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session)
|
|||||||
session.CreatedAt,
|
session.CreatedAt,
|
||||||
session.ExpiresAt,
|
session.ExpiresAt,
|
||||||
string(session.Status),
|
string(session.Status),
|
||||||
|
session.LastActivityAt,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -61,7 +62,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session)
|
|||||||
func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
|
func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||||
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||||
created_by, created_at, expires_at, status, ended_at
|
created_by, created_at, expires_at, status, last_activity_at, ended_at
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, string(id)))
|
`, string(id)))
|
||||||
@ -79,7 +80,7 @@ func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*doma
|
|||||||
func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) {
|
func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) {
|
||||||
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||||
created_by, created_at, expires_at, status, ended_at
|
created_by, created_at, expires_at, status, last_activity_at, ended_at
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_id = $1 AND status = 'active'
|
WHERE project_id = $1 AND status = 'active'
|
||||||
`, string(projectID)))
|
`, string(projectID)))
|
||||||
@ -97,7 +98,7 @@ func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID do
|
|||||||
func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
|
func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||||
created_by, created_at, expires_at, status, ended_at
|
created_by, created_at, expires_at, status, last_activity_at, ended_at
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@ -131,14 +132,36 @@ func (r *SessionRepository) SetEnded(ctx context.Context, id domain.SessionID) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TouchActivity updates the last_activity_at timestamp for an active session.
|
||||||
|
func (r *SessionRepository) TouchActivity(ctx context.Context, id domain.SessionID) error {
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_activity_at = NOW()
|
||||||
|
WHERE id = $1 AND status = 'active'
|
||||||
|
`, string(id))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("touch session activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return domain.ErrSessionNotActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
||||||
func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Session, error) {
|
func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Session, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET status = 'expired', ended_at = NOW()
|
SET status = 'expired', ended_at = NOW()
|
||||||
WHERE status = 'active' AND expires_at < NOW()
|
WHERE status = 'active' AND expires_at < NOW()
|
||||||
|
AND last_activity_at < NOW() - INTERVAL '30 minutes'
|
||||||
RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||||
created_by, created_at, expires_at, status, ended_at
|
created_by, created_at, expires_at, status, last_activity_at, ended_at
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cleanup expired sessions: %w", err)
|
return nil, fmt.Errorf("cleanup expired sessions: %w", err)
|
||||||
@ -175,6 +198,7 @@ func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.S
|
|||||||
&session.CreatedAt,
|
&session.CreatedAt,
|
||||||
&session.ExpiresAt,
|
&session.ExpiresAt,
|
||||||
&status,
|
&status,
|
||||||
|
&session.LastActivityAt,
|
||||||
&endedAt,
|
&endedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
10
internal/db/migrations/024_session_activity.sql
Normal file
10
internal/db/migrations/024_session_activity.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Add activity tracking to sessions for grace-period cleanup
|
||||||
|
-- Sessions with recent activity survive cleanup even if past expires_at
|
||||||
|
|
||||||
|
ALTER TABLE sessions ADD COLUMN last_activity_at TIMESTAMPTZ;
|
||||||
|
UPDATE sessions SET last_activity_at = created_at WHERE last_activity_at IS NULL;
|
||||||
|
ALTER TABLE sessions ALTER COLUMN last_activity_at SET NOT NULL;
|
||||||
|
|
||||||
|
-- Replace simple expires index with composite index for cleanup queries
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_expires;
|
||||||
|
CREATE INDEX idx_sessions_cleanup ON sessions(status, expires_at, last_activity_at) WHERE status = 'active';
|
||||||
@ -55,6 +55,9 @@ type Session struct {
|
|||||||
// Status is the current state of the session.
|
// Status is the current state of the session.
|
||||||
Status SessionStatus
|
Status SessionStatus
|
||||||
|
|
||||||
|
// LastActivityAt is the most recent command or interaction timestamp.
|
||||||
|
LastActivityAt time.Time
|
||||||
|
|
||||||
// EndedAt is when the session was ended (if ended or expired).
|
// EndedAt is when the session was ended (if ended or expired).
|
||||||
EndedAt *time.Time
|
EndedAt *time.Time
|
||||||
}
|
}
|
||||||
@ -68,3 +71,16 @@ func (s *Session) IsActive() bool {
|
|||||||
func (s *Session) IsExpired() bool {
|
func (s *Session) IsExpired() bool {
|
||||||
return s.Status == SessionStatusActive && time.Now().After(s.ExpiresAt)
|
return s.Status == SessionStatusActive && time.Now().After(s.ExpiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsExpiredWithGrace returns true if the session has expired and the grace period
|
||||||
|
// since the last activity has also elapsed. This prevents expiring sessions that
|
||||||
|
// are still actively being used.
|
||||||
|
func (s *Session) IsExpiredWithGrace(gracePeriod time.Duration) bool {
|
||||||
|
if s.Status != SessionStatusActive {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().Before(s.ExpiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(s.LastActivityAt) > gracePeriod
|
||||||
|
}
|
||||||
|
|||||||
761
internal/handlers/checkout_test.go
Normal file
761
internal/handlers/checkout_test.go
Normal file
@ -0,0 +1,761 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupCheckoutTest() (*CheckoutHandler, *mockCheckoutRepository, *mockProjectRepo) {
|
||||||
|
checkoutRepo := newMockCheckoutRepository()
|
||||||
|
projectRepo := newMockProjectRepo()
|
||||||
|
gitRepo := newMockGitRepository()
|
||||||
|
|
||||||
|
projectRepo.projects["test-project"] = &domain.Project{
|
||||||
|
ID: "test-project",
|
||||||
|
Name: "test-project",
|
||||||
|
PodName: "test-project-0",
|
||||||
|
Status: domain.ProjectStatusRunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutService := service.NewCheckoutService(
|
||||||
|
checkoutRepo, gitRepo, projectRepo,
|
||||||
|
service.CheckoutServiceConfig{
|
||||||
|
GiteaURL: "https://git.threesix.ai",
|
||||||
|
DefaultOwner: "threesix",
|
||||||
|
DefaultExpiry: 24 * time.Hour,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := NewCheckoutHandler(checkoutService)
|
||||||
|
return handler, checkoutRepo, projectRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_ListBranches(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
t.Run("list_branches_success", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/branches", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
branches, ok := data["branches"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected branches array, got %T", data["branches"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(branches) != 2 {
|
||||||
|
t.Fatalf("got %d branches, want 2", len(branches))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, b := range branches {
|
||||||
|
bm := b.(map[string]any)
|
||||||
|
names[bm["name"].(string)] = true
|
||||||
|
}
|
||||||
|
if !names["main"] {
|
||||||
|
t.Error("expected 'main' branch in response")
|
||||||
|
}
|
||||||
|
if !names["develop"] {
|
||||||
|
t.Error("expected 'develop' branch in response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list_branches_project_not_found", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/checkout/branches", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_Create(t *testing.T) {
|
||||||
|
t.Run("create_existing_branch", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"branch": "develop"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["id"] == nil || data["id"] == "" {
|
||||||
|
t.Error("expected non-empty id")
|
||||||
|
}
|
||||||
|
if data["clone_url"] == nil || data["clone_url"] == "" {
|
||||||
|
t.Error("expected non-empty clone_url")
|
||||||
|
}
|
||||||
|
if data["instructions"] == nil || data["instructions"] == "" {
|
||||||
|
t.Error("expected non-empty instructions")
|
||||||
|
}
|
||||||
|
if data["branch"] != "develop" {
|
||||||
|
t.Errorf("expected branch=develop, got %v", data["branch"])
|
||||||
|
}
|
||||||
|
if data["project_id"] != "test-project" {
|
||||||
|
t.Errorf("expected project_id=test-project, got %v", data["project_id"])
|
||||||
|
}
|
||||||
|
if data["status"] != "active" {
|
||||||
|
t.Errorf("expected status=active, got %v", data["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_new_branch", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"new_branch": "feature-test"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["branch"] != "feature-test" {
|
||||||
|
t.Errorf("expected branch=feature-test, got %v", data["branch"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_branch_conflict", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"branch": "develop", "new_branch": "feature-test"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_no_branch", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_protected_branch", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"branch": "main"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_project_not_found", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"branch": "develop"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create_expiry_7d", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"branch": "develop", "expires_in": "7d"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expiry is approximately 7 days from now.
|
||||||
|
expiresAtStr, ok := data["expires_at"].(string)
|
||||||
|
if !ok || expiresAtStr == "" {
|
||||||
|
t.Fatal("expected non-empty expires_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, expiresAtStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse expires_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedExpiry := time.Now().Add(7 * 24 * time.Hour)
|
||||||
|
diff := expiresAt.Sub(expectedExpiry)
|
||||||
|
if diff < -time.Minute || diff > time.Minute {
|
||||||
|
t.Errorf("expires_at off by %v, expected ~7 days from now", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_List(t *testing.T) {
|
||||||
|
t.Run("list_empty", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
checkouts, ok := data["checkouts"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkouts array, got %T", data["checkouts"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(checkouts) != 0 {
|
||||||
|
t.Errorf("got %d checkouts, want 0", len(checkouts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list_with_results", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
// Seed a checkout for the test project.
|
||||||
|
checkoutRepo.checkouts["checkout-list-1"] = &domain.Checkout{
|
||||||
|
ID: "checkout-list-1",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
checkouts, ok := data["checkouts"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkouts array, got %T", data["checkouts"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(checkouts) != 1 {
|
||||||
|
t.Errorf("got %d checkouts, want 1", len(checkouts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_Get(t *testing.T) {
|
||||||
|
t.Run("get_success", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{
|
||||||
|
ID: "checkout-123",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/checkout-123", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["id"] != "checkout-123" {
|
||||||
|
t.Errorf("expected id=checkout-123, got %v", data["id"])
|
||||||
|
}
|
||||||
|
if data["branch"] != "develop" {
|
||||||
|
t.Errorf("expected branch=develop, got %v", data["branch"])
|
||||||
|
}
|
||||||
|
if data["project_id"] != "test-project" {
|
||||||
|
t.Errorf("expected project_id=test-project, got %v", data["project_id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get_wrong_project", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, projectRepo := setupCheckoutTest()
|
||||||
|
|
||||||
|
// Add a second project so the URL is valid.
|
||||||
|
projectRepo.projects["other-project"] = &domain.Project{
|
||||||
|
ID: "other-project",
|
||||||
|
Name: "other-project",
|
||||||
|
PodName: "other-project-0",
|
||||||
|
Status: domain.ProjectStatusRunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{
|
||||||
|
ID: "checkout-123",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/other-project/checkout/checkout-123", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get_not_found", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/nonexistent", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_Checkin(t *testing.T) {
|
||||||
|
t.Run("checkin_with_review", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-checkin"] = &domain.Checkout{
|
||||||
|
ID: "checkout-checkin",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-checkin/checkin", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["checkout_id"] != "checkout-checkin" {
|
||||||
|
t.Errorf("expected checkout_id=checkout-checkin, got %v", data["checkout_id"])
|
||||||
|
}
|
||||||
|
if data["status"] != "checked_in" {
|
||||||
|
t.Errorf("expected status=checked_in, got %v", data["status"])
|
||||||
|
}
|
||||||
|
if data["message"] == nil || data["message"] == "" {
|
||||||
|
t.Error("expected non-empty message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("checkin_skip_review", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-skip"] = &domain.Checkout{
|
||||||
|
ID: "checkout-skip",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{"skip_review": true}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-skip/checkin", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["status"] != "checked_in" {
|
||||||
|
t.Errorf("expected status=checked_in, got %v", data["status"])
|
||||||
|
}
|
||||||
|
// With skip_review=true and no work queue, no review task should be set.
|
||||||
|
if data["review_task_id"] != nil && data["review_task_id"] != "" {
|
||||||
|
t.Errorf("expected empty review_task_id with skip_review, got %v", data["review_task_id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("checkin_not_active", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
checkoutRepo.checkouts["checkout-done"] = &domain.Checkout{
|
||||||
|
ID: "checkout-done",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: now.Add(-1 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(23 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusCheckedIn,
|
||||||
|
CheckedInAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-done/checkin", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("checkin_not_found", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/nonexistent/checkin", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("checkin_wrong_project", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, projectRepo := setupCheckoutTest()
|
||||||
|
|
||||||
|
projectRepo.projects["other-project"] = &domain.Project{
|
||||||
|
ID: "other-project",
|
||||||
|
Name: "other-project",
|
||||||
|
PodName: "other-project-0",
|
||||||
|
Status: domain.ProjectStatusRunning,
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-wrong"] = &domain.Checkout{
|
||||||
|
ID: "checkout-wrong",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
body := `{}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/other-project/checkout/checkout-wrong/checkin", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutHandler_Revoke(t *testing.T) {
|
||||||
|
t.Run("revoke_success", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-revoke"] = &domain.Checkout{
|
||||||
|
ID: "checkout-revoke",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-revoke", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data map, got %T", resp["data"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["status"] != "revoked" {
|
||||||
|
t.Errorf("expected status=revoked, got %v", data["status"])
|
||||||
|
}
|
||||||
|
if data["id"] != "checkout-revoke" {
|
||||||
|
t.Errorf("expected id=checkout-revoke, got %v", data["id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoke_not_active", func(t *testing.T) {
|
||||||
|
handler, checkoutRepo, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
checkoutRepo.checkouts["checkout-already-revoked"] = &domain.Checkout{
|
||||||
|
ID: "checkout-already-revoked",
|
||||||
|
ProjectID: "test-project",
|
||||||
|
Branch: "develop",
|
||||||
|
GiteaTokenID: 12345,
|
||||||
|
CloneURL: "https://git.threesix.ai/threesix/test-project.git",
|
||||||
|
CheckedOutBy: "test",
|
||||||
|
CheckedOutAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Status: domain.CheckoutStatusRevoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-already-revoked", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoke_not_found", func(t *testing.T) {
|
||||||
|
handler, _, _ := setupCheckoutTest()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(testAdminAuth)
|
||||||
|
handler.Mount(router)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/nonexistent", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -367,3 +367,71 @@ func (m *mockPreviewManager) DeletePreview(_ context.Context, sessionID string)
|
|||||||
delete(m.previews, sessionID)
|
delete(m.previews, sessionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockExecutor implements port.CommandExecutor for testing.
|
||||||
|
type mockExecutor struct {
|
||||||
|
result *domain.CommandResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockExecutor() *mockExecutor {
|
||||||
|
return &mockExecutor{
|
||||||
|
result: &domain.CommandResult{ExitCode: 0, DurationMs: 100},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) Execute(_ context.Context, _ *domain.Command, _ string, handler domain.OutputHandler) (*domain.CommandResult, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
if handler != nil {
|
||||||
|
handler(domain.OutputLine{Stream: "stdout", Line: "mock output", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
return m.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) Cancel(_ context.Context, _ domain.CommandID) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) PodExists(_ context.Context, _ string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockExecutor) CheckConnection(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockStreamPublisher implements port.StreamPublisher for testing.
|
||||||
|
type mockStreamPublisher struct {
|
||||||
|
events map[string][]port.StreamEvent
|
||||||
|
seq int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockStreamPublisher() *mockStreamPublisher {
|
||||||
|
return &mockStreamPublisher{events: make(map[string][]port.StreamEvent)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStreamPublisher) Subscribe(streamID string) (<-chan port.StreamEvent, func()) {
|
||||||
|
ch := make(chan port.StreamEvent, 100)
|
||||||
|
return ch, func() { close(ch) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStreamPublisher) SubscribeFromID(streamID string, _ string) (<-chan port.StreamEvent, func()) {
|
||||||
|
return m.Subscribe(streamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStreamPublisher) Publish(streamID string, event port.StreamEvent) string {
|
||||||
|
m.seq++
|
||||||
|
event.ID = fmt.Sprintf("%s:%d", streamID, m.seq)
|
||||||
|
m.events[streamID] = append(m.events[streamID], event)
|
||||||
|
return event.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockStreamPublisher) Close(streamID string) {
|
||||||
|
delete(m.events, streamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify mock interfaces at compile time.
|
||||||
|
var _ port.CommandExecutor = (*mockExecutor)(nil)
|
||||||
|
var _ port.StreamPublisher = (*mockStreamPublisher)(nil)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
"github.com/orchard9/rdev/internal/service"
|
"github.com/orchard9/rdev/internal/service"
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
"github.com/orchard9/rdev/internal/validate"
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
@ -18,11 +19,21 @@ import (
|
|||||||
// SessionsHandler handles interactive remote development session endpoints.
|
// SessionsHandler handles interactive remote development session endpoints.
|
||||||
type SessionsHandler struct {
|
type SessionsHandler struct {
|
||||||
sessionService *service.SessionService
|
sessionService *service.SessionService
|
||||||
|
executor port.CommandExecutor
|
||||||
|
streams port.StreamPublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSessionsHandler creates a new sessions handler.
|
// NewSessionsHandler creates a new sessions handler.
|
||||||
func NewSessionsHandler(sessionService *service.SessionService) *SessionsHandler {
|
func NewSessionsHandler(
|
||||||
return &SessionsHandler{sessionService: sessionService}
|
sessionService *service.SessionService,
|
||||||
|
executor port.CommandExecutor,
|
||||||
|
streams port.StreamPublisher,
|
||||||
|
) *SessionsHandler {
|
||||||
|
return &SessionsHandler{
|
||||||
|
sessionService: sessionService,
|
||||||
|
executor: executor,
|
||||||
|
streams: streams,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount registers the session routes.
|
// Mount registers the session routes.
|
||||||
@ -44,6 +55,14 @@ func (h *SessionsHandler) Mount(r api.Router) {
|
|||||||
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||||
Post("/{sid}/checkin", h.Checkin)
|
Post("/{sid}/checkin", h.Checkin)
|
||||||
|
|
||||||
|
// Execute command in session context
|
||||||
|
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||||
|
Post("/{sid}/exec", h.Exec)
|
||||||
|
|
||||||
|
// Stream session command output via SSE
|
||||||
|
r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||||
|
Get("/{sid}/events", h.Events)
|
||||||
|
|
||||||
// Force-terminate session (admin only)
|
// Force-terminate session (admin only)
|
||||||
r.With(auth.RequireScope(auth.ScopeAdmin)).
|
r.With(auth.RequireScope(auth.ScopeAdmin)).
|
||||||
Delete("/{sid}", h.Delete)
|
Delete("/{sid}", h.Delete)
|
||||||
|
|||||||
260
internal/handlers/sessions_exec.go
Normal file
260
internal/handlers/sessions_exec.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionExecRequest is the JSON body for executing a command in a session.
|
||||||
|
type SessionExecRequest struct {
|
||||||
|
Type string `json:"type"` // "claude", "shell", or "git"
|
||||||
|
Prompt string `json:"prompt,omitempty"` // For claude commands
|
||||||
|
Command string `json:"command,omitempty"` // For shell/git commands
|
||||||
|
Args []string `json:"args,omitempty"` // Additional arguments
|
||||||
|
StreamID string `json:"stream_id,omitempty"` // Client-provided stream ID (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionExecResponse is the JSON response for a session exec command.
|
||||||
|
type SessionExecResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StreamURL string `json:"stream_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec executes a command in the context of an active session.
|
||||||
|
// POST /projects/{id}/sessions/{sid}/exec
|
||||||
|
func (h *SessionsHandler) Exec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid project id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sid := chi.URLParam(r, "sid")
|
||||||
|
if sid == "" {
|
||||||
|
api.WriteBadRequest(w, r, "session id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SessionExecRequest
|
||||||
|
if err := api.DecodeJSON(r, &req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate command type and type-specific fields.
|
||||||
|
cmdType := domain.CommandType(req.Type)
|
||||||
|
switch cmdType {
|
||||||
|
case domain.CommandTypeClaude:
|
||||||
|
if req.Prompt == "" {
|
||||||
|
api.WriteBadRequest(w, r, "prompt is required for claude commands")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case domain.CommandTypeShell:
|
||||||
|
if req.Command == "" {
|
||||||
|
api.WriteBadRequest(w, r, "command is required for shell commands")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case domain.CommandTypeGit:
|
||||||
|
if req.Command == "" {
|
||||||
|
api.WriteBadRequest(w, r, "command is required for git commands")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
api.WriteBadRequest(w, r, "type must be claude, shell, or git")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get session and verify it belongs to this project and is active.
|
||||||
|
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||||
|
api.WriteNotFound(w, r, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.WriteInternalError(w, r, "Failed to get session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if string(session.ProjectID) != projectID {
|
||||||
|
api.WriteNotFound(w, r, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !session.IsActive() {
|
||||||
|
api.WriteBadRequest(w, r, "session is not active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch activity.
|
||||||
|
_ = h.sessionService.TouchActivity(ctx, session.ID)
|
||||||
|
|
||||||
|
// Build command args.
|
||||||
|
var args []string
|
||||||
|
switch cmdType {
|
||||||
|
case domain.CommandTypeClaude:
|
||||||
|
args = append([]string{req.Prompt}, req.Args...)
|
||||||
|
case domain.CommandTypeShell, domain.CommandTypeGit:
|
||||||
|
args = append([]string{req.Command}, req.Args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate stream ID.
|
||||||
|
streamID := req.StreamID
|
||||||
|
if streamID == "" {
|
||||||
|
streamID = fmt.Sprintf("session-%s-%d", sid, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &domain.Command{
|
||||||
|
ID: domain.CommandID(streamID),
|
||||||
|
ProjectID: domain.ProjectID(projectID),
|
||||||
|
Type: cmdType,
|
||||||
|
Args: args,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute in background goroutine.
|
||||||
|
go h.executeSessionCommand(r.Context(), cmd, session.PodName, streamID)
|
||||||
|
|
||||||
|
streamURL := fmt.Sprintf("/projects/%s/sessions/%s/events?stream_id=%s", projectID, sid, streamID)
|
||||||
|
|
||||||
|
api.WriteCreated(w, r, SessionExecResponse{
|
||||||
|
ID: streamID,
|
||||||
|
SessionID: string(session.ID),
|
||||||
|
Type: req.Type,
|
||||||
|
Status: "running",
|
||||||
|
StreamURL: streamURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeSessionCommand runs a command and streams output to subscribers.
|
||||||
|
func (h *SessionsHandler) executeSessionCommand(parentCtx context.Context, cmd *domain.Command, podName, streamID string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), TimeoutLongRunning)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) {
|
||||||
|
h.streams.Publish(streamID, port.StreamEvent{
|
||||||
|
Type: "output",
|
||||||
|
Data: map[string]any{
|
||||||
|
"line": line.Line,
|
||||||
|
"stream": line.Stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish completion event.
|
||||||
|
h.streams.Publish(streamID, port.StreamEvent{
|
||||||
|
Type: "complete",
|
||||||
|
Data: map[string]any{
|
||||||
|
"exit_code": result.ExitCode,
|
||||||
|
"duration_ms": result.DurationMs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Allow subscribers time to receive the completion event before cleanup.
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
h.streams.Close(streamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events streams session command output via Server-Sent Events.
|
||||||
|
// GET /projects/{id}/sessions/{sid}/events
|
||||||
|
func (h *SessionsHandler) Events(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid project id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sid := chi.URLParam(r, "sid")
|
||||||
|
if sid == "" {
|
||||||
|
api.WriteBadRequest(w, r, "session id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamID := r.URL.Query().Get("stream_id")
|
||||||
|
lastEventID := r.Header.Get("Last-Event-ID")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Verify session exists and belongs to project.
|
||||||
|
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||||
|
api.WriteNotFound(w, r, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.WriteInternalError(w, r, "Failed to get session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if string(session.ProjectID) != projectID {
|
||||||
|
api.WriteNotFound(w, r, "session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch activity.
|
||||||
|
_ = h.sessionService.TouchActivity(ctx, session.ID)
|
||||||
|
|
||||||
|
// Set SSE headers.
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
api.WriteInternalError(w, r, "SSE not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to events with Last-Event-ID support.
|
||||||
|
var events <-chan port.StreamEvent
|
||||||
|
var cleanup func()
|
||||||
|
if lastEventID != "" {
|
||||||
|
events, cleanup = h.streams.SubscribeFromID(streamID, lastEventID)
|
||||||
|
} else {
|
||||||
|
events, cleanup = h.streams.Subscribe(streamID)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Send initial connected event.
|
||||||
|
writeSSE(w, flusher, "connected", map[string]any{
|
||||||
|
"session_id": sid,
|
||||||
|
"stream_id": streamID,
|
||||||
|
"reconnecting": lastEventID != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream events until client disconnects or stream closes.
|
||||||
|
reqCtx := r.Context()
|
||||||
|
heartbeat := time.NewTicker(30 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-reqCtx.Done():
|
||||||
|
return
|
||||||
|
case event, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSSEWithID(w, flusher, event.ID, event.Type, event.Data)
|
||||||
|
if event.Type == "complete" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
writeSSE(w, flusher, "heartbeat", map[string]any{
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,6 +76,18 @@ func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domai
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockSessionRepository) TouchActivity(_ context.Context, id domain.SessionID) error {
|
||||||
|
if m.err != nil {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
s, ok := m.sessions[string(id)]
|
||||||
|
if !ok || s.Status != domain.SessionStatusActive {
|
||||||
|
return domain.ErrSessionNotActive
|
||||||
|
}
|
||||||
|
s.LastActivityAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error {
|
func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error {
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return m.err
|
return m.err
|
||||||
@ -144,8 +156,17 @@ func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOp
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCheckoutRepository) ListByProject(_ context.Context, _ domain.ProjectID) ([]*domain.Checkout, error) {
|
func (m *mockCheckoutRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
|
||||||
return nil, nil
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
var result []*domain.Checkout
|
||||||
|
for _, c := range m.checkouts {
|
||||||
|
if c.ProjectID == projectID {
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
|
func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
|
||||||
@ -211,7 +232,10 @@ func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectR
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := NewSessionsHandler(sessionService)
|
executor := newMockExecutor()
|
||||||
|
streams := newMockStreamPublisher()
|
||||||
|
|
||||||
|
handler := NewSessionsHandler(sessionService, executor, streams)
|
||||||
return handler, sessionRepo, projectRepo
|
return handler, sessionRepo, projectRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,6 @@ type PreviewOptions struct {
|
|||||||
// SessionID is used as the resource name prefix (e.g., "session-{id}").
|
// SessionID is used as the resource name prefix (e.g., "session-{id}").
|
||||||
SessionID string
|
SessionID string
|
||||||
|
|
||||||
// Namespace is the K8s namespace (typically "rdev").
|
|
||||||
Namespace string
|
|
||||||
|
|
||||||
// PodName is the target pod to route traffic to.
|
// PodName is the target pod to route traffic to.
|
||||||
PodName string
|
PodName string
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,9 @@ type SessionRepository interface {
|
|||||||
// SetEnded marks a session as ended with a timestamp.
|
// SetEnded marks a session as ended with a timestamp.
|
||||||
SetEnded(ctx context.Context, id domain.SessionID) error
|
SetEnded(ctx context.Context, id domain.SessionID) error
|
||||||
|
|
||||||
|
// TouchActivity updates the last_activity_at timestamp for an active session.
|
||||||
|
TouchActivity(ctx context.Context, id domain.SessionID) error
|
||||||
|
|
||||||
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
||||||
CleanupExpired(ctx context.Context) ([]*domain.Session, error)
|
CleanupExpired(ctx context.Context) ([]*domain.Session, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
@ -451,29 +450,3 @@ func (s *CheckoutService) buildCloneURLs(token, owner, repo string) (authURL, ba
|
|||||||
return authURL, baseURL, nil
|
return authURL, baseURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeCloneURL removes the token from a clone URL for safe logging/display.
|
|
||||||
func SanitizeCloneURL(cloneURL string) string {
|
|
||||||
u, err := url.Parse(cloneURL)
|
|
||||||
if err != nil {
|
|
||||||
return cloneURL
|
|
||||||
}
|
|
||||||
u.User = nil
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractRepoFromURL extracts owner/repo from a git URL.
|
|
||||||
func ExtractRepoFromURL(gitURL string) (owner, repo string, err error) {
|
|
||||||
u, err := url.Parse(gitURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove .git suffix and leading slash
|
|
||||||
path := strings.TrimSuffix(strings.TrimPrefix(u.Path, "/"), ".git")
|
|
||||||
parts := strings.SplitN(path, "/", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", "", fmt.Errorf("invalid git url path: %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts[0], parts[1], nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -138,16 +138,18 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session record first to get the ID.
|
// Create session record first to get the ID.
|
||||||
|
now := time.Now()
|
||||||
session := &domain.Session{
|
session := &domain.Session{
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
CheckoutID: checkoutResult.Checkout.ID,
|
CheckoutID: checkoutResult.Checkout.ID,
|
||||||
PodName: project.PodName,
|
PodName: project.PodName,
|
||||||
PreviewURL: previewURL,
|
PreviewURL: previewURL,
|
||||||
PreviewHost: previewHost,
|
PreviewHost: previewHost,
|
||||||
CreatedBy: req.CreatedBy,
|
CreatedBy: req.CreatedBy,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: now,
|
||||||
ExpiresAt: time.Now().Add(expiry),
|
ExpiresAt: now.Add(expiry),
|
||||||
Status: domain.SessionStatusActive,
|
LastActivityAt: now,
|
||||||
|
Status: domain.SessionStatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||||
@ -159,7 +161,6 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq
|
|||||||
// Create K8s preview (Service + Ingress).
|
// Create K8s preview (Service + Ingress).
|
||||||
if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{
|
if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{
|
||||||
SessionID: string(session.ID),
|
SessionID: string(session.ID),
|
||||||
Namespace: "rdev",
|
|
||||||
PodName: project.PodName,
|
PodName: project.PodName,
|
||||||
Host: previewHost,
|
Host: previewHost,
|
||||||
Port: previewPort,
|
Port: previewPort,
|
||||||
@ -186,10 +187,9 @@ Clone URL: %s
|
|||||||
Branch: %s
|
Branch: %s
|
||||||
Pod: %s
|
Pod: %s
|
||||||
|
|
||||||
Run commands via:
|
Run commands via session:
|
||||||
POST /projects/%s/claude (Claude Code commands)
|
POST /projects/%s/sessions/%s/exec (execute commands)
|
||||||
POST /projects/%s/shell (shell commands)
|
GET /projects/%s/sessions/%s/events (SSE output)
|
||||||
GET /projects/%s/stream (SSE output)
|
|
||||||
|
|
||||||
End session:
|
End session:
|
||||||
POST /projects/%s/sessions/%s/checkin
|
POST /projects/%s/sessions/%s/checkin
|
||||||
@ -199,7 +199,8 @@ Session expires: %s`,
|
|||||||
checkoutResult.AuthenticatedCloneURL,
|
checkoutResult.AuthenticatedCloneURL,
|
||||||
branch,
|
branch,
|
||||||
project.PodName,
|
project.PodName,
|
||||||
req.ProjectID, req.ProjectID, req.ProjectID,
|
req.ProjectID, session.ID,
|
||||||
|
req.ProjectID, session.ID,
|
||||||
req.ProjectID, session.ID,
|
req.ProjectID, session.ID,
|
||||||
session.ExpiresAt.Format(time.RFC3339),
|
session.ExpiresAt.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
@ -293,6 +294,11 @@ func (s *SessionService) ListByProject(ctx context.Context, projectID domain.Pro
|
|||||||
return s.sessionRepo.ListByProject(ctx, projectID)
|
return s.sessionRepo.ListByProject(ctx, projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TouchActivity updates the last activity timestamp for an active session.
|
||||||
|
func (s *SessionService) TouchActivity(ctx context.Context, id domain.SessionID) error {
|
||||||
|
return s.sessionRepo.TouchActivity(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
// ForceEnd forcefully ends a session without checkout checkin (admin use).
|
// ForceEnd forcefully ends a session without checkout checkin (admin use).
|
||||||
func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error {
|
func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error {
|
||||||
log := logging.FromContext(ctx).WithService("SessionService")
|
log := logging.FromContext(ctx).WithService("SessionService")
|
||||||
@ -370,6 +376,7 @@ func (s *SessionService) teardownSession(ctx context.Context, session *domain.Se
|
|||||||
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
_ = s.checkoutSvc.Revoke(ctx, session.CheckoutID)
|
||||||
return s.sessionRepo.SetEnded(ctx, session.ID)
|
return s.sessionRepo.SetEnded(ctx, session.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1050
internal/service/session_service_test.go
Normal file
1050
internal/service/session_service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user