mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
Create new task environments under sanitized, bounded readable segments: <workspace-slug>-<workspace-short>/<issue-key>-<task-short>/workdir. The readable name is the visible part; the substance is moving task identity off the directory name. A human-readable segment is mutable — workspace slugs and issue prefixes change — so workspace/task identity now lives in .task_owner, .gc_meta.json and a stable ID-keyed index under .task_roots that freezes one physical root per task for its lifetime. GC and disk-usage read that authoritative identity instead of inferring a workspace from a path string, which also fixes active and preparation-crashed roots being attributed to a nonexistent workspace. Existing workdirs are not renamed or migrated, legacy roots are adopted into the index, and both layouts render privacy-safe relative paths. Labels are lowercased ASCII with separators and traversal collapsed, segments are capped at 24 characters so the two together stay within the previous 49-character Windows path budget, and stable ID suffixes drawn from the UUIDv7 random tail keep roots distinct across renames and case-insensitive filesystems. Closes #6063 Co-authored-by: iiwish <[email protected]> Co-authored-by: J <[email protected]>
819 lines
29 KiB
Go
819 lines
29 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
func writeFile(t *testing.T, path string, size int) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buf := make([]byte, size)
|
|
for i := range buf {
|
|
buf[i] = 'x'
|
|
}
|
|
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_AggregatesAndCategorizes verifies the happy-path: each
|
|
// task directory is sized, categorized by GC meta kind, and aggregated into
|
|
// per-workspace totals matching the per-task totals.
|
|
func TestScanDiskUsage_AggregatesAndCategorizes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsA := "11111111-1111-1111-1111-111111111111"
|
|
wsB := "22222222-2222-2222-2222-222222222222"
|
|
|
|
taskA1 := filepath.Join(root, wsA, "aaaaaaaa")
|
|
writeFile(t, filepath.Join(taskA1, "workdir/main.go"), 1000)
|
|
writeFile(t, filepath.Join(taskA1, "workdir/node_modules/dep/index.js"), 4000)
|
|
mustWriteMeta(t, taskA1, execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: "issue-1",
|
|
WorkspaceID: wsA,
|
|
CompletedAt: time.Now().Add(-3 * time.Hour),
|
|
})
|
|
|
|
taskA2 := filepath.Join(root, wsA, "bbbbbbbb")
|
|
writeFile(t, filepath.Join(taskA2, "workdir/notes.md"), 500)
|
|
mustWriteMeta(t, taskA2, execenv.GCMeta{
|
|
Kind: execenv.GCKindChat,
|
|
ChatSessionID: "chat-1",
|
|
WorkspaceID: wsA,
|
|
CompletedAt: time.Now().Add(-1 * time.Hour),
|
|
})
|
|
|
|
taskB1 := filepath.Join(root, wsB, "cccccccc")
|
|
writeFile(t, filepath.Join(taskB1, "workdir/result.txt"), 2000)
|
|
// No meta — exercises the unknown-kind / mtime-fallback path. Backdate
|
|
// the dir mtime so the fallback produces a measurable age (a freshly
|
|
// created dir has mtime=now, which would round to 0 seconds).
|
|
backdate := time.Now().Add(-2 * time.Hour)
|
|
if err := os.Chtimes(taskB1, backdate, backdate); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ScanDiskUsage(root, []string{"node_modules", ".next", ".turbo"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
|
|
if len(report.Tasks) != 3 {
|
|
t.Fatalf("expected 3 tasks, got %d", len(report.Tasks))
|
|
}
|
|
|
|
byShort := map[string]TaskDiskUsage{}
|
|
for _, task := range report.Tasks {
|
|
byShort[task.TaskShort] = task
|
|
}
|
|
|
|
a1 := byShort["aaaaaaaa"]
|
|
if a1.Kind != string(execenv.GCKindIssue) {
|
|
t.Errorf("task a1 kind = %q, want %q", a1.Kind, execenv.GCKindIssue)
|
|
}
|
|
// Size includes main.go (1000) + node_modules subtree (4000) + the
|
|
// .gc_meta.json control file we wrote. Bound the meta overhead so we
|
|
// don't drift if the meta JSON shape changes.
|
|
if a1.SizeBytes < 5000 || a1.SizeBytes > 5000+1024 {
|
|
t.Errorf("task a1 size = %d, want in [5000, 6024]", a1.SizeBytes)
|
|
}
|
|
if a1.ArtifactSizeBytes != 4000 {
|
|
t.Errorf("task a1 artifact size = %d, want 4000", a1.ArtifactSizeBytes)
|
|
}
|
|
if a1.AgeSeconds < 60 {
|
|
t.Errorf("task a1 age_seconds = %d, want >= 60 (CompletedAt -3h)", a1.AgeSeconds)
|
|
}
|
|
if a1.WorkspaceShort != ShortID(wsA) {
|
|
t.Errorf("task a1 workspace_short = %q, want %q", a1.WorkspaceShort, ShortID(wsA))
|
|
}
|
|
|
|
a2 := byShort["bbbbbbbb"]
|
|
if a2.Kind != string(execenv.GCKindChat) {
|
|
t.Errorf("task a2 kind = %q, want chat", a2.Kind)
|
|
}
|
|
if a2.SizeBytes < 500 || a2.SizeBytes > 500+1024 {
|
|
t.Errorf("task a2 size = %d, want in [500, 1524]", a2.SizeBytes)
|
|
}
|
|
if a2.ArtifactSizeBytes != 0 {
|
|
t.Errorf("task a2 artifact size = %d, want 0", a2.ArtifactSizeBytes)
|
|
}
|
|
|
|
b1 := byShort["cccccccc"]
|
|
if b1.Kind != DiskUsageKindUnknown {
|
|
t.Errorf("task b1 kind = %q, want %q", b1.Kind, DiskUsageKindUnknown)
|
|
}
|
|
if b1.SizeBytes != 2000 {
|
|
t.Errorf("task b1 size = %d, want 2000 (no meta file)", b1.SizeBytes)
|
|
}
|
|
if b1.AgeSeconds < 60 {
|
|
t.Errorf("task b1 age_seconds = %d, want >= 60 (mtime backdated 2h)", b1.AgeSeconds)
|
|
}
|
|
|
|
if report.TotalSizeBytes != a1.SizeBytes+a2.SizeBytes+b1.SizeBytes {
|
|
t.Errorf("total size = %d, want sum of per-task sizes (%d)",
|
|
report.TotalSizeBytes, a1.SizeBytes+a2.SizeBytes+b1.SizeBytes)
|
|
}
|
|
if report.TotalArtifactSizeBytes != 4000 {
|
|
t.Errorf("total artifact size = %d, want 4000", report.TotalArtifactSizeBytes)
|
|
}
|
|
|
|
wsByID := map[string]WorkspaceDiskUsage{}
|
|
for _, ws := range report.Workspaces {
|
|
wsByID[ws.WorkspaceID] = ws
|
|
}
|
|
if wsByID[wsA].SizeBytes != a1.SizeBytes+a2.SizeBytes {
|
|
t.Errorf("workspace A size = %d, want %d (a1+a2)",
|
|
wsByID[wsA].SizeBytes, a1.SizeBytes+a2.SizeBytes)
|
|
}
|
|
if wsByID[wsA].ArtifactSizeBytes != 4000 {
|
|
t.Errorf("workspace A artifact size = %d, want 4000", wsByID[wsA].ArtifactSizeBytes)
|
|
}
|
|
if wsByID[wsA].TaskCount != 2 {
|
|
t.Errorf("workspace A task count = %d, want 2", wsByID[wsA].TaskCount)
|
|
}
|
|
if wsByID[wsB].SizeBytes != 2000 {
|
|
t.Errorf("workspace B size = %d, want 2000", wsByID[wsB].SizeBytes)
|
|
}
|
|
|
|
// Workspace A's artifact ratio: 4000 reclaimable / a1+a2 size. Match
|
|
// within float tolerance so a small meta-file delta doesn't break it.
|
|
wantARatio := 4000.0 / float64(a1.SizeBytes+a2.SizeBytes)
|
|
if got := wsByID[wsA].ArtifactRatio; got < wantARatio-0.005 || got > wantARatio+0.005 {
|
|
t.Errorf("workspace A artifact_ratio = %f, want ~%f", got, wantARatio)
|
|
}
|
|
// Workspace B has no artifact subtree at all → ratio must be 0, not NaN.
|
|
if got := wsByID[wsB].ArtifactRatio; got != 0 {
|
|
t.Errorf("workspace B artifact_ratio = %f, want 0", got)
|
|
}
|
|
|
|
// Scan-wide counts must reflect the full scan, not the (un-truncated
|
|
// here) slice — they're the contract callers rely on once --top kicks in.
|
|
if report.TotalTaskCount != 3 {
|
|
t.Errorf("total_task_count = %d, want 3", report.TotalTaskCount)
|
|
}
|
|
if report.TotalWorkspaceCount != 2 {
|
|
t.Errorf("total_workspace_count = %d, want 2", report.TotalWorkspaceCount)
|
|
}
|
|
if report.TotalArtifactRatio <= 0 || report.TotalArtifactRatio > 1 {
|
|
t.Errorf("total_artifact_ratio = %f, want in (0, 1]", report.TotalArtifactRatio)
|
|
}
|
|
|
|
// Tasks must be sorted by size descending — the consumer treats this as
|
|
// a stable contract for `--top N` slicing.
|
|
for i := 1; i < len(report.Tasks); i++ {
|
|
if report.Tasks[i-1].SizeBytes < report.Tasks[i].SizeBytes {
|
|
t.Errorf("tasks not sorted by size desc: %d < %d at idx %d",
|
|
report.Tasks[i-1].SizeBytes, report.Tasks[i].SizeBytes, i)
|
|
}
|
|
}
|
|
|
|
// JSON round-trip — guards the field names the issue spec calls out.
|
|
raw, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
`"kind"`,
|
|
`"parent_status"`,
|
|
`"age_seconds"`,
|
|
`"size_bytes"`,
|
|
`"artifact_size_bytes"`,
|
|
`"workspace_id"`,
|
|
`"task_short"`,
|
|
`"artifact_ratio"`,
|
|
`"managed_artifact_subpaths"`,
|
|
`"total_task_count"`,
|
|
`"total_workspace_count"`,
|
|
`"total_artifact_ratio"`,
|
|
} {
|
|
if !strings.Contains(string(raw), want) {
|
|
t.Errorf("JSON missing required field %s: %s", want, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanDiskUsage_MixedLayoutsUseMetadataIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
workspaceID := "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
|
readableTaskID := "22222222-ee7a-4603-a72d-b659c34a1dc3"
|
|
legacyTaskSegment := "a548b2390cb2"
|
|
readableTaskSegment := "mul-6063-b659c34a1dc3"
|
|
|
|
legacyDir := filepath.Join(root, workspaceID, legacyTaskSegment)
|
|
writeFile(t, filepath.Join(legacyDir, "workdir", "legacy.txt"), 10)
|
|
mustWriteMeta(t, legacyDir, execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: "issue-legacy",
|
|
WorkspaceID: workspaceID,
|
|
CompletedAt: time.Now().Add(-time.Hour),
|
|
})
|
|
|
|
readableDir := filepath.Join(root, "asset-feed-a548b2390cb2", readableTaskSegment)
|
|
writeFile(t, filepath.Join(readableDir, "workdir", "readable.txt"), 20)
|
|
mustWriteMeta(t, readableDir, execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue,
|
|
IssueID: "issue-readable",
|
|
TaskID: readableTaskID,
|
|
WorkspaceID: workspaceID,
|
|
CompletedAt: time.Now().Add(-time.Hour),
|
|
})
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
if report.TotalTaskCount != 2 || report.TotalWorkspaceCount != 1 {
|
|
t.Fatalf("totals = tasks:%d workspaces:%d, want 2/1", report.TotalTaskCount, report.TotalWorkspaceCount)
|
|
}
|
|
for _, task := range report.Tasks {
|
|
if task.WorkspaceID != workspaceID {
|
|
t.Errorf("task %q workspace_id = %q, want metadata id %q", task.Path, task.WorkspaceID, workspaceID)
|
|
}
|
|
}
|
|
byShort := map[string]TaskDiskUsage{}
|
|
for _, task := range report.Tasks {
|
|
byShort[task.TaskShort] = task
|
|
}
|
|
if _, ok := byShort[legacyTaskSegment]; !ok {
|
|
t.Errorf("legacy task was not reported under physical directory segment %q", legacyTaskSegment)
|
|
}
|
|
if _, ok := byShort[readableTaskSegment]; !ok {
|
|
t.Errorf("readable task was not reported under physical directory segment %q", readableTaskSegment)
|
|
}
|
|
}
|
|
|
|
func TestScanDiskUsage_ReadableActiveRootUsesOwnerIdentityWithoutGCMeta(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
workspaceID := "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
|
taskID := "22222222-ee7a-4603-a72d-b659c34a1dc3"
|
|
env, err := execenv.Prepare(execenv.PrepareParams{
|
|
WorkspacesRoot: root,
|
|
WorkspaceID: workspaceID,
|
|
WorkspaceSlug: "Asset Feed",
|
|
TaskID: taskID,
|
|
IssueIdentifier: "MUL-6063",
|
|
AgentName: "Active Task",
|
|
}, slog.Default())
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
defer env.Cleanup(true)
|
|
if _, err := os.Stat(filepath.Join(env.RootDir, ".gc_meta.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("fixture must represent an active root without completion metadata; stat err = %v", err)
|
|
}
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
if len(report.Tasks) != 1 {
|
|
t.Fatalf("tasks = %d, want 1", len(report.Tasks))
|
|
}
|
|
usage := report.Tasks[0]
|
|
if usage.WorkspaceID != workspaceID {
|
|
t.Fatalf("workspace_id = %q, want owner identity %q", usage.WorkspaceID, workspaceID)
|
|
}
|
|
wantTaskSegment := filepath.Base(env.RootDir)
|
|
if usage.TaskShort != wantTaskSegment {
|
|
t.Fatalf("task_short = %q, want physical directory segment %q", usage.TaskShort, wantTaskSegment)
|
|
}
|
|
if len(report.Workspaces) != 1 || report.Workspaces[0].WorkspaceID != workspaceID {
|
|
t.Fatalf("workspace aggregate = %+v, want authoritative workspace %q", report.Workspaces, workspaceID)
|
|
}
|
|
}
|
|
|
|
func TestScanDiskUsage_ManagedCodexSandboxIsExactAndDeduplicated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
writeFile(t, filepath.Join(taskDir, "codex-home/.sandbox-bin/codex.exe"), 300)
|
|
writeFile(t, filepath.Join(taskDir, "workdir/repo/.sandbox-bin/cache"), 400)
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage managed-only: %v", err)
|
|
}
|
|
if got := report.Tasks[0].SizeBytes; got != 700 {
|
|
t.Fatalf("size_bytes=%d, want 700", got)
|
|
}
|
|
if got := report.Tasks[0].ArtifactSizeBytes; got != 300 {
|
|
t.Fatalf("artifact_size_bytes=%d, want exact managed 300", got)
|
|
}
|
|
if got := strings.Join(report.ManagedArtifactSubpaths, ","); got != "codex-home/.sandbox-bin" {
|
|
t.Fatalf("managed artifact paths=%q, want codex-home/.sandbox-bin", got)
|
|
}
|
|
|
|
report, err = ScanDiskUsage(root, []string{".sandbox-bin"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage broad basename: %v", err)
|
|
}
|
|
if got := report.Tasks[0].ArtifactSizeBytes; got != 700 {
|
|
t.Fatalf("artifact_size_bytes=%d, want 700 without double counting", got)
|
|
}
|
|
}
|
|
|
|
func TestScanDiskUsage_LegacyMetaAgeUsesMetaFileMTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "llllllll-llll-llll-llll-llllllllllll"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 10)
|
|
mustWriteMeta(t, taskDir, execenv.GCMeta{
|
|
Kind: execenv.GCKindIssue, IssueID: "issue-legacy", WorkspaceID: wsID,
|
|
})
|
|
oldRoot := time.Now().Add(-30 * 24 * time.Hour)
|
|
if err := os.Chtimes(taskDir, oldRoot, oldRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recentMeta := time.Now().Add(-2 * time.Hour)
|
|
if err := os.Chtimes(filepath.Join(taskDir, ".gc_meta.json"), recentMeta, recentMeta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
if got := report.Tasks[0].AgeSeconds; got < int64(time.Hour.Seconds()) || got > int64((3*time.Hour).Seconds()) {
|
|
t.Fatalf("age_seconds=%d, want metadata age near 2h instead of stale task root age", got)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_EmptyWorkspaceArtifactRatio guards the total=0 edge:
|
|
// a workspace whose tasks have no measurable bytes (or no files at all) must
|
|
// still report ArtifactRatio=0, never NaN. The CLI table renders this column,
|
|
// and `NaN%` would surface in the user's terminal otherwise.
|
|
func TestScanDiskUsage_EmptyWorkspaceArtifactRatio(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "00000000-0000-0000-0000-000000000000"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ScanDiskUsage(root, []string{"node_modules"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
if len(report.Workspaces) != 1 {
|
|
t.Fatalf("expected 1 workspace, got %d", len(report.Workspaces))
|
|
}
|
|
if got := report.Workspaces[0].ArtifactRatio; got != 0 {
|
|
t.Errorf("empty workspace artifact_ratio = %f, want 0 (no NaN)", got)
|
|
}
|
|
if got := report.TotalArtifactRatio; got != 0 {
|
|
t.Errorf("empty scan total_artifact_ratio = %f, want 0 (no NaN)", got)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_CountsGitButNeverAsArtifact pins the two halves of the
|
|
// .git rule. A .git subtree IS real footprint, so it counts toward size_bytes
|
|
// (a full task-dir reclaim frees it, and dirSize reports it that way). But it
|
|
// must never count as an artifact, even when something inside it matches an
|
|
// artifact basename, because cleanTaskArtifacts refuses to reclaim in there.
|
|
func TestScanDiskUsage_CountsGitButNeverAsArtifact(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "wwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
|
|
writeFile(t, filepath.Join(taskDir, "workdir/.git/objects/pack"), 9999)
|
|
writeFile(t, filepath.Join(taskDir, "workdir/.git/node_modules/x"), 5555)
|
|
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 100)
|
|
|
|
report, err := ScanDiskUsage(root, []string{"node_modules"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
|
|
if len(report.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(report.Tasks))
|
|
}
|
|
got := report.Tasks[0]
|
|
const wantSize = 100 + 9999 + 5555
|
|
if got.SizeBytes != wantSize {
|
|
t.Errorf("size_bytes = %d, want %d (main.go plus the whole .git tree)", got.SizeBytes, wantSize)
|
|
}
|
|
if got.ArtifactSizeBytes != 0 {
|
|
t.Errorf("artifact_size_bytes = %d, want 0 (node_modules under .git is not reclaimable)", got.ArtifactSizeBytes)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_DoesNotFollowSymlinks guards the second safety
|
|
// constraint. A symlinked artifact directory must not be sized — neither
|
|
// the link itself nor its target — because cleanTaskArtifacts won't reclaim
|
|
// it either.
|
|
func TestScanDiskUsage_DoesNotFollowSymlinks(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink semantics differ on windows")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
outside := t.TempDir()
|
|
writeFile(t, filepath.Join(outside, "huge.bin"), 10000)
|
|
|
|
wsID := "ssssssss-ssss-ssss-ssss-ssssssssssss"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
writeFile(t, filepath.Join(taskDir, "workdir/main.go"), 100)
|
|
if err := os.Symlink(outside, filepath.Join(taskDir, "workdir/node_modules")); err != nil {
|
|
t.Skipf("symlink not supported: %v", err)
|
|
}
|
|
// Symlinked regular file too — the link's target lives outside taskDir
|
|
// and must not be summed.
|
|
if err := os.Symlink(filepath.Join(outside, "huge.bin"), filepath.Join(taskDir, "workdir/big-link")); err != nil {
|
|
t.Skipf("symlink not supported: %v", err)
|
|
}
|
|
|
|
report, err := ScanDiskUsage(root, []string{"node_modules"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
|
|
if len(report.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(report.Tasks))
|
|
}
|
|
got := report.Tasks[0]
|
|
if got.SizeBytes != 100 {
|
|
t.Errorf("size_bytes = %d, want 100 (only main.go; symlinks ignored)", got.SizeBytes)
|
|
}
|
|
if got.ArtifactSizeBytes != 0 {
|
|
t.Errorf("artifact_size_bytes = %d, want 0 (symlinked node_modules ignored)", got.ArtifactSizeBytes)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_MissingRoot ensures a daemon that has never run yet
|
|
// (workspaces dir doesn't exist) returns an empty report, not an error.
|
|
func TestScanDiskUsage_MissingRoot(t *testing.T) {
|
|
t.Parallel()
|
|
report, err := ScanDiskUsage(filepath.Join(t.TempDir(), "does-not-exist"), nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage on missing root returned error: %v", err)
|
|
}
|
|
if len(report.Tasks) != 0 || len(report.Workspaces) != 0 {
|
|
t.Errorf("expected empty report, got %+v", report)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_RejectsPatternsWithSeparators mirrors the GC safety check:
|
|
// a pattern containing "/" or "\\" is meaningless for basename matching and
|
|
// must be silently dropped, not interpreted as a path.
|
|
func TestScanDiskUsage_RejectsPatternsWithSeparators(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr"
|
|
taskDir := filepath.Join(root, wsID, "tttttttt")
|
|
writeFile(t, filepath.Join(taskDir, "workdir/node_modules/x"), 1000)
|
|
|
|
report, err := ScanDiskUsage(root, []string{"workdir/node_modules", "../etc"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
if got := report.Tasks[0].ArtifactSizeBytes; got != 0 {
|
|
t.Errorf("artifact_size_bytes = %d, want 0 (separator-bearing patterns dropped)", got)
|
|
}
|
|
if got := report.ArtifactPatterns; len(got) != 0 {
|
|
t.Errorf("ArtifactPatterns = %v, want empty (all dropped)", got)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsageRoots_SumsAcrossRoots verifies the cross-root aggregate:
|
|
// each root keeps its own labeled report and the grand totals are the sum of
|
|
// every root's full scan. A missing root contributes an empty report, not an
|
|
// error, so a never-used profile root doesn't break the aggregate.
|
|
func TestScanDiskUsageRoots_SumsAcrossRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootA := t.TempDir()
|
|
writeFile(t, filepath.Join(rootA, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "t1", "workdir/main.go"), 100)
|
|
|
|
rootB := t.TempDir()
|
|
writeFile(t, filepath.Join(rootB, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "t1", "workdir/big"), 300)
|
|
writeFile(t, filepath.Join(rootB, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "t2", "workdir/main.go"), 50)
|
|
|
|
missing := filepath.Join(t.TempDir(), "never-ran")
|
|
|
|
agg, err := ScanDiskUsageRoots([]DiskUsageRoot{
|
|
{Profile: "", Root: rootA},
|
|
{Profile: "desktop-host", Root: rootB},
|
|
{Profile: "never-ran", Root: missing},
|
|
}, []string{"node_modules"})
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsageRoots: %v", err)
|
|
}
|
|
|
|
if len(agg.Roots) != 3 {
|
|
t.Fatalf("Roots len = %d, want 3 (missing root still listed, empty)", len(agg.Roots))
|
|
}
|
|
if agg.Roots[0].Profile != "" || agg.Roots[1].Profile != "desktop-host" {
|
|
t.Fatalf("root profiles not preserved in order: %+v", agg.Roots)
|
|
}
|
|
if agg.Roots[2].Report.TotalTaskCount != 0 {
|
|
t.Fatalf("missing root TotalTaskCount = %d, want 0", agg.Roots[2].Report.TotalTaskCount)
|
|
}
|
|
if agg.TotalTaskCount != 3 {
|
|
t.Fatalf("TotalTaskCount = %d, want 3 across roots", agg.TotalTaskCount)
|
|
}
|
|
if agg.TotalSizeBytes != 450 {
|
|
t.Fatalf("TotalSizeBytes = %d, want 450 (100 + 300 + 50)", agg.TotalSizeBytes)
|
|
}
|
|
if agg.TotalWorkspaceCount != 2 {
|
|
t.Fatalf("TotalWorkspaceCount = %d, want 2", agg.TotalWorkspaceCount)
|
|
}
|
|
if got := strings.Join(agg.ManagedArtifactSubpaths, ","); got != "codex-home/.sandbox-bin" {
|
|
t.Fatalf("managed artifact paths=%q, want codex-home/.sandbox-bin", got)
|
|
}
|
|
}
|
|
|
|
func mustWriteMeta(t *testing.T, taskDir string, meta execenv.GCMeta) {
|
|
t.Helper()
|
|
data, err := json.Marshal(meta)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(taskDir, ".gc_meta.json"), data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// issueTask builds a minimal issue-kind task row for ResolveParentStatuses.
|
|
func issueTask(wsID, taskShort, issueID string) TaskDiskUsage {
|
|
return TaskDiskUsage{
|
|
WorkspaceID: wsID,
|
|
TaskShort: taskShort,
|
|
Kind: string(execenv.GCKindIssue),
|
|
ParentID: issueID,
|
|
}
|
|
}
|
|
|
|
// TestResolveParentStatuses_FillsIssueTasks covers the main path: statuses land
|
|
// on the right rows, ids shared by several task dirs are asked for once, each
|
|
// workspace is queried separately, and non-issue kinds are left alone.
|
|
func TestResolveParentStatuses_FillsIssueTasks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wsA := "11111111-1111-1111-1111-111111111111"
|
|
wsB := "22222222-2222-2222-2222-222222222222"
|
|
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
|
|
issueTask(wsA, "aaaa1111", "issue-1"),
|
|
// Same issue, second workdir — must resolve without a second ask.
|
|
issueTask(wsA, "aaaa2222", "issue-1"),
|
|
issueTask(wsA, "aaaa3333", "issue-2"),
|
|
issueTask(wsB, "bbbb1111", "issue-3"),
|
|
{WorkspaceID: wsA, TaskShort: "cccc1111", Kind: string(execenv.GCKindChat), ParentID: "chat-1"},
|
|
{WorkspaceID: wsA, TaskShort: "dddd1111", Kind: DiskUsageKindUnknown},
|
|
}}
|
|
|
|
asked := map[string][]string{}
|
|
fetch := func(_ context.Context, workspaceID string, issueIDs []string) (map[string]string, error) {
|
|
asked[workspaceID] = append(asked[workspaceID], issueIDs...)
|
|
out := map[string]string{}
|
|
for _, id := range issueIDs {
|
|
switch id {
|
|
case "issue-1":
|
|
out[id] = "done"
|
|
case "issue-2":
|
|
out[id] = "in_progress"
|
|
case "issue-3":
|
|
out[id] = "cancelled"
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
|
|
t.Fatalf("ResolveParentStatuses: %v", err)
|
|
}
|
|
|
|
want := []string{"done", "done", "in_progress", "cancelled", "", ""}
|
|
for i, wantStatus := range want {
|
|
if got := report.Tasks[i].ParentStatus; got != wantStatus {
|
|
t.Errorf("task[%d] (%s) parent_status = %q, want %q",
|
|
i, report.Tasks[i].TaskShort, got, wantStatus)
|
|
}
|
|
}
|
|
|
|
if len(asked[wsA]) != 2 {
|
|
t.Errorf("workspace A asked for %v, want exactly 2 de-duplicated ids", asked[wsA])
|
|
}
|
|
if len(asked[wsB]) != 1 {
|
|
t.Errorf("workspace B asked for %v, want exactly 1 id", asked[wsB])
|
|
}
|
|
}
|
|
|
|
// TestResolveParentStatuses_UnresolvedStaysBlank ensures an id the server does
|
|
// not return (deleted issue, or one this token cannot see) reads as unknown
|
|
// rather than being filled with a placeholder that looks like a real status.
|
|
func TestResolveParentStatuses_UnresolvedStaysBlank(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wsID := "11111111-1111-1111-1111-111111111111"
|
|
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
|
|
issueTask(wsID, "aaaa1111", "issue-known"),
|
|
issueTask(wsID, "aaaa2222", "issue-missing"),
|
|
}}
|
|
|
|
fetch := func(_ context.Context, _ string, _ []string) (map[string]string, error) {
|
|
return map[string]string{"issue-known": "todo"}, nil
|
|
}
|
|
|
|
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
|
|
t.Fatalf("ResolveParentStatuses: %v", err)
|
|
}
|
|
if report.Tasks[0].ParentStatus != "todo" {
|
|
t.Errorf("known issue status = %q, want todo", report.Tasks[0].ParentStatus)
|
|
}
|
|
if report.Tasks[1].ParentStatus != "" {
|
|
t.Errorf("missing issue status = %q, want empty", report.Tasks[1].ParentStatus)
|
|
}
|
|
}
|
|
|
|
// TestResolveParentStatuses_ChunksLargeWorkspaces verifies a root with more
|
|
// issues than the server's batch cap is split the same way the GC loop splits
|
|
// it, instead of being sent as one oversized request.
|
|
func TestResolveParentStatuses_ChunksLargeWorkspaces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wsID := "11111111-1111-1111-1111-111111111111"
|
|
total := issueGCBatchSize + 1
|
|
tasks := make([]TaskDiskUsage, 0, total)
|
|
for i := range total {
|
|
tasks = append(tasks, issueTask(wsID, fmt.Sprintf("task%04d", i), fmt.Sprintf("issue-%04d", i)))
|
|
}
|
|
report := &DiskUsageReport{Tasks: tasks}
|
|
|
|
var chunkSizes []int
|
|
fetch := func(_ context.Context, _ string, issueIDs []string) (map[string]string, error) {
|
|
chunkSizes = append(chunkSizes, len(issueIDs))
|
|
out := make(map[string]string, len(issueIDs))
|
|
for _, id := range issueIDs {
|
|
out[id] = "done"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
if err := ResolveParentStatuses(context.Background(), report, fetch); err != nil {
|
|
t.Fatalf("ResolveParentStatuses: %v", err)
|
|
}
|
|
|
|
if len(chunkSizes) != 2 {
|
|
t.Fatalf("chunk sizes = %v, want 2 chunks", chunkSizes)
|
|
}
|
|
if chunkSizes[0] != issueGCBatchSize || chunkSizes[1] != 1 {
|
|
t.Errorf("chunk sizes = %v, want [%d 1]", chunkSizes, issueGCBatchSize)
|
|
}
|
|
for i := range report.Tasks {
|
|
if report.Tasks[i].ParentStatus != "done" {
|
|
t.Fatalf("task[%d] parent_status = %q, want done", i, report.Tasks[i].ParentStatus)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolveParentStatuses_PartialFailureKeepsOtherWorkspaces pins the
|
|
// best-effort contract: one workspace failing must not blank out the rest, and
|
|
// the error still surfaces so the CLI can warn.
|
|
func TestResolveParentStatuses_PartialFailureKeepsOtherWorkspaces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wsGood := "11111111-1111-1111-1111-111111111111"
|
|
wsBad := "22222222-2222-2222-2222-222222222222"
|
|
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
|
|
issueTask(wsGood, "aaaa1111", "issue-good"),
|
|
issueTask(wsBad, "bbbb1111", "issue-bad"),
|
|
}}
|
|
|
|
fetch := func(_ context.Context, workspaceID string, _ []string) (map[string]string, error) {
|
|
if workspaceID == wsBad {
|
|
return nil, errors.New("boom")
|
|
}
|
|
return map[string]string{"issue-good": "done"}, nil
|
|
}
|
|
|
|
err := ResolveParentStatuses(context.Background(), report, fetch)
|
|
if err == nil {
|
|
t.Fatal("expected the failing workspace's error to surface")
|
|
}
|
|
if report.Tasks[0].ParentStatus != "done" {
|
|
t.Errorf("healthy workspace status = %q, want done", report.Tasks[0].ParentStatus)
|
|
}
|
|
if report.Tasks[1].ParentStatus != "" {
|
|
t.Errorf("failed workspace status = %q, want empty", report.Tasks[1].ParentStatus)
|
|
}
|
|
}
|
|
|
|
// TestResolveParentStatuses_NoFetcherIsNoOp keeps `disk-usage` usable offline
|
|
// or logged out: with no way to resolve, the scan result passes through
|
|
// untouched instead of erroring.
|
|
func TestResolveParentStatuses_NoFetcherIsNoOp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
report := &DiskUsageReport{Tasks: []TaskDiskUsage{
|
|
issueTask("11111111-1111-1111-1111-111111111111", "aaaa1111", "issue-1"),
|
|
}}
|
|
if err := ResolveParentStatuses(context.Background(), report, nil); err != nil {
|
|
t.Fatalf("nil fetcher should be a no-op, got %v", err)
|
|
}
|
|
if report.Tasks[0].ParentStatus != "" {
|
|
t.Errorf("parent_status = %q, want empty", report.Tasks[0].ParentStatus)
|
|
}
|
|
if err := ResolveParentStatuses(context.Background(), nil, func(context.Context, string, []string) (map[string]string, error) {
|
|
t.Fatal("fetcher must not run for a nil report")
|
|
return nil, nil
|
|
}); err != nil {
|
|
t.Fatalf("nil report should be a no-op, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_ReportsRepoCacheSeparately pins the accounting split: the
|
|
// bare-repo cache is measured (it used to be invisible, which made the reported
|
|
// total silently disagree with the user's file manager) but kept out of the
|
|
// task totals, since every task checks out from it and none contains it.
|
|
func TestScanDiskUsage_ReportsRepoCacheSeparately(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "11111111-1111-1111-1111-111111111111"
|
|
writeFile(t, filepath.Join(root, wsID, "aaaaaaaa", "workdir/main.go"), 1000)
|
|
|
|
// .repos/<workspace>/<repo>/... — the repo dir is the unit the GC evicts.
|
|
writeFile(t, filepath.Join(root, ".repos", wsID, "widgets.git", "objects/pack/x"), 4000)
|
|
writeFile(t, filepath.Join(root, ".repos", wsID, "gadgets.git", "objects/pack/y"), 2000)
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
|
|
if report.RepoCacheSizeBytes != 6000 {
|
|
t.Errorf("repo_cache_size_bytes = %d, want 6000", report.RepoCacheSizeBytes)
|
|
}
|
|
if report.RepoCacheCount != 2 {
|
|
t.Errorf("repo_cache_count = %d, want 2", report.RepoCacheCount)
|
|
}
|
|
if report.TotalSizeBytes != 1000 {
|
|
t.Errorf("total_size_bytes = %d, want 1000 (task dirs only, cache excluded)", report.TotalSizeBytes)
|
|
}
|
|
if report.TotalWorkspaceCount != 1 {
|
|
t.Errorf("total_workspace_count = %d, want 1 (.repos is not a workspace)", report.TotalWorkspaceCount)
|
|
}
|
|
}
|
|
|
|
// TestScanDiskUsage_SkipsDaemonInternalDotDirs keeps caches like .skill-cache
|
|
// out of the per-workspace table, where they used to surface as bogus
|
|
// workspace rows alongside the real ones.
|
|
func TestScanDiskUsage_SkipsDaemonInternalDotDirs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
wsID := "11111111-1111-1111-1111-111111111111"
|
|
writeFile(t, filepath.Join(root, wsID, "aaaaaaaa", "workdir/main.go"), 1000)
|
|
writeFile(t, filepath.Join(root, ".skill-cache", "v1", "bundle", "skill.md"), 500)
|
|
|
|
report, err := ScanDiskUsage(root, nil)
|
|
if err != nil {
|
|
t.Fatalf("ScanDiskUsage: %v", err)
|
|
}
|
|
|
|
if report.TotalWorkspaceCount != 1 {
|
|
t.Fatalf("total_workspace_count = %d, want 1", report.TotalWorkspaceCount)
|
|
}
|
|
for _, ws := range report.Workspaces {
|
|
if strings.HasPrefix(ws.WorkspaceID, ".") {
|
|
t.Errorf("dot-directory %q reported as a workspace", ws.WorkspaceID)
|
|
}
|
|
}
|
|
if report.TotalSizeBytes != 1000 {
|
|
t.Errorf("total_size_bytes = %d, want 1000 (skill cache excluded)", report.TotalSizeBytes)
|
|
}
|
|
}
|