diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-13 14:05:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-13 14:05:38 +0300 |
| commit | 604f5aa7603498c4f7cc9a649a86d525b824e6c9 (patch) | |
| tree | 814adfdd098e1594b74cbda3396b06723a469d3b /internal/askcli | |
| parent | 00981e0d9cd0bc00c3e21ec0124ffbcc52b4762a (diff) | |
Release v0.33.0: ask dependency UX and start gatingv0.33.0
Show only task alias IDs on ask info Depends lines. Block ask start
until every dependency is completed. Bump version to 0.33.0.
Made-with: Cursor
Diffstat (limited to 'internal/askcli')
| -rw-r--r-- | internal/askcli/command_info_add_test.go | 12 | ||||
| -rw-r--r-- | internal/askcli/command_start_deps.go | 44 | ||||
| -rw-r--r-- | internal/askcli/command_start_deps_test.go | 157 | ||||
| -rw-r--r-- | internal/askcli/command_write.go | 24 | ||||
| -rw-r--r-- | internal/askcli/formatter.go | 10 | ||||
| -rw-r--r-- | internal/askcli/formatter_test.go | 5 |
6 files changed, 233 insertions, 19 deletions
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) } |
