mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(cli): show snapshots in task logs (#21787)
This commit is contained in:
committed by
GitHub
parent
c75c57c250
commit
efcfee80b8
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -95,6 +96,76 @@ ExtractCommandPathsLoop:
|
||||
}
|
||||
}
|
||||
|
||||
// Output captures stdout and stderr from an invocation and formats them with
|
||||
// prefixes for golden file testing, preserving their interleaved order.
|
||||
type Output struct {
|
||||
mu sync.Mutex
|
||||
stdout bytes.Buffer
|
||||
stderr bytes.Buffer
|
||||
combined bytes.Buffer
|
||||
}
|
||||
|
||||
// prefixWriter wraps a buffer and prefixes each line with a given prefix.
|
||||
type prefixWriter struct {
|
||||
mu *sync.Mutex
|
||||
prefix string
|
||||
raw *bytes.Buffer
|
||||
combined *bytes.Buffer
|
||||
line bytes.Buffer // buffer for incomplete lines
|
||||
}
|
||||
|
||||
// Write implements io.Writer, adding a prefix to each complete line.
|
||||
func (w *prefixWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
// Write unprefixed to raw buffer.
|
||||
_, _ = w.raw.Write(p)
|
||||
|
||||
// Append to line buffer.
|
||||
_, _ = w.line.Write(p)
|
||||
|
||||
// Split on newlines.
|
||||
lines := bytes.Split(w.line.Bytes(), []byte{'\n'})
|
||||
|
||||
// Write all complete lines (all but the last, which may be incomplete).
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
_, _ = w.combined.WriteString(w.prefix)
|
||||
_, _ = w.combined.Write(lines[i])
|
||||
_ = w.combined.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Keep the last line (incomplete) in the buffer.
|
||||
w.line.Reset()
|
||||
_, _ = w.line.Write(lines[len(lines)-1])
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Capture sets up stdout and stderr writers on the invocation that prefix each
|
||||
// line with "out: " or "err: " while preserving their order.
|
||||
func Capture(inv *serpent.Invocation) *Output {
|
||||
output := &Output{}
|
||||
inv.Stdout = &prefixWriter{mu: &output.mu, prefix: "out: ", raw: &output.stdout, combined: &output.combined}
|
||||
inv.Stderr = &prefixWriter{mu: &output.mu, prefix: "err: ", raw: &output.stderr, combined: &output.combined}
|
||||
return output
|
||||
}
|
||||
|
||||
// Golden returns the formatted output with lines prefixed by "err: " or "out: ".
|
||||
func (o *Output) Golden() []byte {
|
||||
return o.combined.Bytes()
|
||||
}
|
||||
|
||||
// Stdout returns the unprefixed stdout content for parsing (e.g., JSON).
|
||||
func (o *Output) Stdout() string {
|
||||
return o.stdout.String()
|
||||
}
|
||||
|
||||
// Stderr returns the unprefixed stderr content.
|
||||
func (o *Output) Stderr() string {
|
||||
return o.stderr.String()
|
||||
}
|
||||
|
||||
// TestGoldenFile will test the given bytes slice input against the
|
||||
// golden file with the given file name, optionally using the given replacements.
|
||||
func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements map[string]string) {
|
||||
|
||||
@@ -54,12 +54,38 @@ func (r *RootCmd) taskLogs() *serpent.Command {
|
||||
return xerrors.Errorf("get task logs: %w", err)
|
||||
}
|
||||
|
||||
// Handle snapshot responses (paused/initializing/pending tasks).
|
||||
if logs.Snapshot {
|
||||
if logs.SnapshotAt == nil {
|
||||
// No snapshot captured yet.
|
||||
cliui.Warnf(inv.Stderr,
|
||||
"Task is %s. No snapshot available (snapshot may have failed during pause, resume your task to view logs).\n",
|
||||
task.Status)
|
||||
}
|
||||
|
||||
// Snapshot exists with logs, show warning with count.
|
||||
if len(logs.Logs) > 0 {
|
||||
if len(logs.Logs) == 1 {
|
||||
cliui.Warnf(inv.Stderr, "Task is %s. Showing last 1 message from snapshot.\n", task.Status)
|
||||
} else {
|
||||
cliui.Warnf(inv.Stderr, "Task is %s. Showing last %d messages from snapshot.\n", task.Status, len(logs.Logs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty logs for both snapshot/live, table/json.
|
||||
if len(logs.Logs) == 0 {
|
||||
cliui.Infof(inv.Stderr, "No task logs found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
out, err := formatter.Format(ctx, logs.Logs)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("format task logs: %w", err)
|
||||
}
|
||||
|
||||
if out == "" {
|
||||
// Defensive check (shouldn't happen given count check above).
|
||||
cliui.Infof(inv.Stderr, "No task logs found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
+136
-24
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func Test_TaskLogs(t *testing.T) {
|
||||
func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testMessages := []agentapisdk.Message{
|
||||
@@ -44,23 +44,20 @@ func Test_TaskLogs(t *testing.T) {
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client // user already has access to their own workspace
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
|
||||
inv.Stdout = &stdout
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify JSON is valid.
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, logs, 2)
|
||||
require.Equal(t, "What is 1 + 1?", logs[0].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type)
|
||||
require.Equal(t, "2", logs[1].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("ByTaskID_JSON", func(t *testing.T) {
|
||||
@@ -70,23 +67,20 @@ func Test_TaskLogs(t *testing.T) {
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
|
||||
inv.Stdout = &stdout
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify JSON is valid.
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs)
|
||||
err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, logs, 2)
|
||||
require.Equal(t, "What is 1 + 1?", logs[0].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type)
|
||||
require.Equal(t, "2", logs[1].Content)
|
||||
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("ByTaskID_Table", func(t *testing.T) {
|
||||
@@ -96,19 +90,15 @@ func Test_TaskLogs(t *testing.T) {
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
inv.Stdout = &stdout
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
output := stdout.String()
|
||||
require.Contains(t, output, "What is 1 + 1?")
|
||||
require.Contains(t, output, "2")
|
||||
require.Contains(t, output, "input")
|
||||
require.Contains(t, output, "output")
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("TaskNotFound_ByName", func(t *testing.T) {
|
||||
@@ -160,6 +150,128 @@ func Test_TaskLogs(t *testing.T) {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, assert.AnError.Error())
|
||||
})
|
||||
|
||||
t.Run("SnapshotWithLogs_Table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPaused, testMessages)
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("SnapshotWithLogs_JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPaused, testMessages)
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify JSON is valid.
|
||||
var logs []codersdk.TaskLogEntry
|
||||
err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("SnapshotWithSingleMessage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
singleMessage := []agentapisdk.Message{
|
||||
{
|
||||
Id: 0,
|
||||
Role: agentapisdk.RoleUser,
|
||||
Content: "Single message",
|
||||
Time: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPending, singleMessage)
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("SnapshotEmptyLogs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusInitializing, []agentapisdk.Message{})
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
|
||||
t.Run("InitializingTaskSnapshot", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusInitializing, testMessages)
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify output format with golden file.
|
||||
clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func fakeAgentAPITaskLogsOK(messages []agentapisdk.Message) map[string]http.HandlerFunc {
|
||||
|
||||
@@ -20,7 +20,11 @@ import (
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -271,6 +275,93 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
|
||||
return userClient, task
|
||||
}
|
||||
|
||||
// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot.
|
||||
func setupCLITaskTestWithSnapshot(ctx context.Context, t *testing.T, status codersdk.TaskStatus, messages []agentapisdk.Message) (*codersdk.Client, codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
ownerUser, err := ownerClient.User(ctx, owner.UserID.String())
|
||||
require.NoError(t, err)
|
||||
ownerSubject := coderdtest.AuthzUserSubject(ownerUser)
|
||||
|
||||
task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status)
|
||||
|
||||
// Create snapshot envelope with agentapi format.
|
||||
envelope := coderd.TaskLogSnapshotEnvelope{
|
||||
Format: "agentapi",
|
||||
Data: agentapisdk.GetMessagesResponse{
|
||||
Messages: messages,
|
||||
},
|
||||
}
|
||||
snapshotJSON, err := json.Marshal(envelope)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert snapshot into database.
|
||||
snapshotTime := time.Now()
|
||||
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
|
||||
TaskID: task.ID,
|
||||
LogSnapshot: json.RawMessage(snapshotJSON),
|
||||
LogSnapshotCreatedAt: snapshotTime,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return userClient, task
|
||||
}
|
||||
|
||||
// setupCLITaskTestWithoutSnapshot creates a task in the specified status without a log snapshot.
|
||||
func setupCLITaskTestWithoutSnapshot(t *testing.T, status codersdk.TaskStatus) (*codersdk.Client, codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status)
|
||||
|
||||
return userClient, task
|
||||
}
|
||||
|
||||
// createTaskInStatus creates a task in the specified status using dbfake.
|
||||
func createTaskInStatus(t *testing.T, db database.Store, orgID, ownerID uuid.UUID, status codersdk.TaskStatus) codersdk.Task {
|
||||
t.Helper()
|
||||
|
||||
builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
}).
|
||||
WithTask(database.TaskTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
}, nil)
|
||||
|
||||
switch status {
|
||||
case codersdk.TaskStatusPending:
|
||||
builder = builder.Pending()
|
||||
case codersdk.TaskStatusInitializing:
|
||||
builder = builder.Starting()
|
||||
case codersdk.TaskStatusPaused:
|
||||
builder = builder.Seed(database.WorkspaceBuild{
|
||||
Transition: database.WorkspaceTransitionStop,
|
||||
})
|
||||
default:
|
||||
require.Fail(t, "unsupported task status in test helper", "status: %s", status)
|
||||
}
|
||||
|
||||
resp := builder.Do()
|
||||
|
||||
return codersdk.Task{
|
||||
ID: resp.Task.ID,
|
||||
Name: resp.Task.Name,
|
||||
OrganizationID: resp.Task.OrganizationID,
|
||||
OwnerID: resp.Task.OwnerID,
|
||||
WorkspaceID: resp.Task.WorkspaceID,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// createAITaskTemplate creates a template configured for AI tasks with a sidebar app.
|
||||
func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID, opts ...aiTemplateOpt) codersdk.Template {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
out: [
|
||||
out: {
|
||||
out: "id": 0,
|
||||
out: "content": "What is 1 + 1?",
|
||||
out: "type": "input",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: },
|
||||
out: {
|
||||
out: "id": 1,
|
||||
out: "content": "2",
|
||||
out: "type": "output",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: }
|
||||
out: ]
|
||||
@@ -0,0 +1,3 @@
|
||||
out: TYPE CONTENT
|
||||
out: input What is 1 + 1?
|
||||
out: output 2
|
||||
@@ -0,0 +1,14 @@
|
||||
out: [
|
||||
out: {
|
||||
out: "id": 0,
|
||||
out: "content": "What is 1 + 1?",
|
||||
out: "type": "input",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: },
|
||||
out: {
|
||||
out: "id": 1,
|
||||
out: "content": "2",
|
||||
out: "type": "output",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: }
|
||||
out: ]
|
||||
@@ -0,0 +1,5 @@
|
||||
err: WARN: Task is initializing. Showing last 2 messages from snapshot.
|
||||
err:
|
||||
out: TYPE CONTENT
|
||||
out: input What is 1 + 1?
|
||||
out: output 2
|
||||
@@ -0,0 +1 @@
|
||||
err: No task logs found.
|
||||
@@ -0,0 +1,16 @@
|
||||
err: WARN: Task is paused. Showing last 2 messages from snapshot.
|
||||
err:
|
||||
out: [
|
||||
out: {
|
||||
out: "id": 0,
|
||||
out: "content": "What is 1 + 1?",
|
||||
out: "type": "input",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: },
|
||||
out: {
|
||||
out: "id": 1,
|
||||
out: "content": "2",
|
||||
out: "type": "output",
|
||||
out: "time": "====[timestamp]====="
|
||||
out: }
|
||||
out: ]
|
||||
@@ -0,0 +1,5 @@
|
||||
err: WARN: Task is paused. Showing last 2 messages from snapshot.
|
||||
err:
|
||||
out: TYPE CONTENT
|
||||
out: input What is 1 + 1?
|
||||
out: output 2
|
||||
@@ -0,0 +1,4 @@
|
||||
err: WARN: Task is initializing. Showing last 1 message from snapshot.
|
||||
err:
|
||||
out: TYPE CONTENT
|
||||
out: input Single message
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
err: WARN: Task is paused. No snapshot available (snapshot may have failed during pause, resume your task to view logs).
|
||||
err:
|
||||
err: No task logs found.
|
||||
Reference in New Issue
Block a user