summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--integrationtests/ask_test.go55
-rw-r--r--internal/askcli/command_info_add_test.go12
-rw-r--r--internal/askcli/command_start_deps.go44
-rw-r--r--internal/askcli/command_start_deps_test.go157
-rw-r--r--internal/askcli/command_write.go24
-rw-r--r--internal/askcli/formatter.go10
-rw-r--r--internal/askcli/formatter_test.go5
-rw-r--r--internal/version.go2
8 files changed, 285 insertions, 24 deletions
diff --git a/integrationtests/ask_test.go b/integrationtests/ask_test.go
index c122ede..9dab14e 100644
--- a/integrationtests/ask_test.go
+++ b/integrationtests/ask_test.go
@@ -640,10 +640,7 @@ func TestInfoShowsAllDependencies(t *testing.T) {
alias1 := mustTaskAlias(t, ctx, dependency1)
alias2 := mustTaskAlias(t, ctx, dependency2)
- wantDepends := []string{
- alias1 + " (" + dependency1 + ")",
- alias2 + " (" + dependency2 + ")",
- }
+ wantDepends := []string{alias1, alias2}
slices.Sort(wantDepends)
if !slices.Equal(ti.Depends, wantDepends) {
t.Fatalf("info dependencies = %+v, want %+v", ti.Depends, wantDepends)
@@ -702,6 +699,56 @@ func TestStart(t *testing.T) {
}
}
+func TestStartBlockedUntilDependenciesCompleted(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ defer cancel()
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ dep, err := createTask(ctx, "integration test dependency for start gate")
+ if err != nil {
+ t.Fatalf("failed to create dependency task: %v", err)
+ }
+ defer deleteTask(ctx, dep)
+
+ dependent, err := createTask(ctx, "integration test dependent for start gate")
+ if err != nil {
+ t.Fatalf("failed to create dependent task: %v", err)
+ }
+ defer deleteTask(ctx, dependent)
+
+ if _, stderr, code := runAsk(ctx, []string{"dep", "add", dependent, dep}); code != 0 {
+ t.Fatalf("dep add failed with code %d: stderr=%s", code, stderr.String())
+ }
+
+ _, stderr, code := runAsk(ctx, []string{"start", dependent})
+ if code == 0 {
+ t.Fatalf("start should fail when a dependency is not completed")
+ }
+ if !strings.Contains(stderr.String(), "cannot start until all dependencies are completed") {
+ t.Fatalf("stderr = %q, want dependency gate message", stderr.String())
+ }
+
+ if _, stderr, code := runAsk(ctx, []string{"done", dep}); code != 0 {
+ t.Fatalf("done on dependency failed with code %d: stderr=%s", code, stderr.String())
+ }
+
+ stdout, stderr, code := runAsk(ctx, []string{"start", dependent})
+ if code != 0 {
+ t.Fatalf("start should succeed after dependency is completed: code=%d stderr=%s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "ok ") {
+ t.Fatalf("start stdout = %q, want success line", stdout.String())
+ }
+
+ ti, ok := getTaskInfoFast(ctx, dependent)
+ if !ok {
+ t.Fatalf("could not get task info after start")
+ }
+ if ti.Started != "yes" {
+ t.Errorf("task started state = %q, want yes", ti.Started)
+ }
+}
+
func TestStop(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go
index 0e5b366..b6f0bd0 100644
--- a/internal/askcli/command_info_add_test.go
+++ b/internal/askcli/command_info_add_test.go
@@ -57,9 +57,12 @@ func TestHandleInfo_Success(t *testing.T) {
if !strings.Contains(output, "Started: no") {
t.Fatalf("output missing explicit started state: %s", output)
}
- if !strings.Contains(output, "Depends: 1 (dep-1)") {
+ if !strings.Contains(output, "Depends: 1") {
t.Fatalf("output missing formatted dependency alias: %s", output)
}
+ if strings.Contains(output, "dep-1") {
+ t.Fatalf("output should not list dependency UUID when alias exists: %s", output)
+ }
}
func TestHandleInfo_Success_DebugShowsUUID(t *testing.T) {
@@ -135,11 +138,12 @@ func TestHandleInfo_AssignsDependencyAliasesFromInfo(t *testing.T) {
}
output := stdout.String()
- if !strings.Contains(output, "Depends:") ||
- !strings.Contains(output, "(dep-a)") ||
- !strings.Contains(output, "(dep-b)") {
+ if !strings.Contains(output, "Depends: 1, 2") {
t.Fatalf("output missing assigned dependency aliases: %s", output)
}
+ if strings.Contains(output, "dep-a") || strings.Contains(output, "dep-b") {
+ t.Fatalf("output should not list dependency UUIDs when aliases exist: %s", output)
+ }
}
func TestHandleInfo_AliasSelector(t *testing.T) {
diff --git a/internal/askcli/command_start_deps.go b/internal/askcli/command_start_deps.go
new file mode 100644
index 0000000..94a6a9e
--- /dev/null
+++ b/internal/askcli/command_start_deps.go
@@ -0,0 +1,44 @@
+package askcli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// verifyDependenciesCompletedForStart returns 0 if every dependency is completed,
+// or 1 after writing an error to stderr when the task must not be started yet.
+func (d *Dispatcher) verifyDependenciesCompletedForStart(ctx context.Context, task TaskExport, stderr io.Writer) int {
+ if len(task.Depends) == 0 {
+ return 0
+ }
+ aliases, err := ensureTaskAliasesForUUIDs(task.Depends)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err)
+ return 1
+ }
+ var incomplete []string
+ for _, depUUID := range task.Depends {
+ depTasks, code, err := d.exportTasks(ctx, []string{"uuid:" + depUUID, "export"}, stderr)
+ if err != nil {
+ writeInfoError(stderr, err)
+ return code
+ }
+ if len(depTasks) == 0 {
+ fmt.Fprintf(stderr, "error: dependency task not found (%s)\n", displayTaskAlias(depUUID, aliases))
+ return 1
+ }
+ status := strings.ToLower(strings.TrimSpace(depTasks[0].Status))
+ if status != "completed" {
+ label := displayTaskAlias(depUUID, aliases)
+ incomplete = append(incomplete, fmt.Sprintf("%s (%s)", label, depTasks[0].Status))
+ }
+ }
+ if len(incomplete) > 0 {
+ fmt.Fprintf(stderr, "error: cannot start until all dependencies are completed; incomplete: %s\n",
+ strings.Join(incomplete, ", "))
+ return 1
+ }
+ return 0
+}
diff --git a/internal/askcli/command_start_deps_test.go b/internal/askcli/command_start_deps_test.go
new file mode 100644
index 0000000..9ba197b
--- /dev/null
+++ b/internal/askcli/command_start_deps_test.go
@@ -0,0 +1,157 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestHandleStart_BlockedWhenDependencyNotCompleted(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ fixedNow := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return fixedNow }
+ defer func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ var startCalls int
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ startCalls++
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 1 {
+ t.Fatalf("start code = %d, want 1", code)
+ }
+ if startCalls != 0 {
+ t.Fatalf("task start ran %d times, want 0", startCalls)
+ }
+ if !strings.Contains(stderr.String(), "cannot start until all dependencies are completed") {
+ t.Fatalf("stderr = %q, want dependency gate message", stderr.String())
+ }
+ if !strings.Contains(stderr.String(), "1 (pending)") {
+ t.Fatalf("stderr should name incomplete dependency with alias and status: %q", stderr.String())
+ }
+}
+
+func TestHandleStart_AllowedWhenDependencyCompleted(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ fixedNow := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return fixedNow }
+ defer func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"completed","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("start code = %d stderr=%q", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "ok 0") {
+ t.Fatalf("stdout = %q, want ok + alias", stdout.String())
+ }
+}
+
+func TestHandleStart_CompletedStatusIsCaseInsensitive(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ fixedNow := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return fixedNow }
+ defer func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"COMPLETED","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("start code = %d stderr=%q", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "ok 0") {
+ t.Fatalf("stdout = %q, want ok + alias", stdout.String())
+ }
+}
diff --git a/internal/askcli/command_write.go b/internal/askcli/command_write.go
index d8dbf5b..6887ff7 100644
--- a/internal/askcli/command_write.go
+++ b/internal/askcli/command_write.go
@@ -67,11 +67,25 @@ func (d *Dispatcher) handleStart(ctx context.Context, args []string, stdout, std
_, _ = io.WriteString(stderr, "error: ask start requires an ID or UUID argument\n")
return 1, nil
}
- return d.runSingleTaskCommand(ctx, args[1], stdout, stderr, func(resolved resolvedTaskSelector) []string {
- // uuid:<uuid> is used as the filter so taskwarrior selects the exact task;
- // the action verb follows the filter.
- return []string{"uuid:" + resolved.UUID, "start"}
- })
+ resolved, tasks, code, err := d.resolveTaskSelector(ctx, args[1], stderr)
+ if err != nil {
+ writeInfoError(stderr, err)
+ return code, nil
+ }
+ if code != 0 {
+ return code, nil
+ }
+ if depCode := d.verifyDependenciesCompletedForStart(ctx, tasks[0], stderr); depCode != 0 {
+ return depCode, nil
+ }
+
+ var outBuf bytes.Buffer
+ code, err = d.runner.Run(ctx, []string{"uuid:" + resolved.UUID, "start"}, nil, &outBuf, io.Discard)
+ if code != 0 {
+ return code, err
+ }
+ _, _ = io.WriteString(stdout, FormatSuccess(displayResolvedTaskID(resolved)))
+ return 0, nil
}
func (d *Dispatcher) handleStop(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) {
diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go
index 7f9b846..8d62125 100644
--- a/internal/askcli/formatter.go
+++ b/internal/askcli/formatter.go
@@ -192,16 +192,8 @@ func FormatError(err error, taskID string) string {
func formatTaskDependencies(depends []string, aliases map[string]string) string {
items := make([]string, 0, len(depends))
for _, uuid := range depends {
- items = append(items, formatTaskReference(uuid, aliases))
+ items = append(items, displayTaskAlias(uuid, aliases))
}
slices.Sort(items)
return strings.Join(items, ", ")
}
-
-func formatTaskReference(uuid string, aliases map[string]string) string {
- alias := strings.TrimSpace(aliases[uuid])
- if alias == "" || alias == uuid {
- return uuid
- }
- return fmt.Sprintf("%s (%s)", alias, uuid)
-}
diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go
index e473ed4..9788430 100644
--- a/internal/askcli/formatter_test.go
+++ b/internal/askcli/formatter_test.go
@@ -195,9 +195,12 @@ func TestFormatTaskInfo(t *testing.T) {
if !strings.Contains(output, "cli, agent") {
t.Fatalf("FormatTaskInfo missing tags: %s", output)
}
- if !strings.Contains(output, "1 (dep-1)") || !strings.Contains(output, "2 (dep-2)") {
+ if !strings.Contains(output, "Depends: 1, 2") {
t.Fatalf("FormatTaskInfo missing formatted depends: %s", output)
}
+ if strings.Contains(output, "dep-1") || strings.Contains(output, "dep-2") {
+ t.Fatalf("FormatTaskInfo should not list dependency UUIDs when aliases exist: %s", output)
+ }
if !strings.Contains(output, "First note") {
t.Fatalf("FormatTaskInfo missing annotation: %s", output)
}
diff --git a/internal/version.go b/internal/version.go
index 62d500a..b7ef401 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.32.0"
+const Version = "0.33.0"