diff --git a/README.md b/README.md index d8552c0..326276f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ craken workspace list craken workspace create --name test0 craken channel send --workspace test0 --channel general hello craken dm send --workspace test0 --target orca hello +craken wiki save --workspace test0 --existing-title Home --content-file ./home.md --base-version 12 craken workspace tail --workspace test0 --pretty craken commands --format text craken do workspaces.list diff --git a/docs/architecture.md b/docs/architecture.md index 784bbf3..bc33d69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,8 @@ The Worker remains the source of truth for product behavior. Dedicated commands call stable API routes directly, while `commands`, `do`, and raw HTTP commands discover and invoke the server-owned `/api/client` catalog so new routes and command syntax are usable before a CLI release is needed. +Wiki saves follow the service's optimistic concurrency contract. Creating a missing page can omit `--base-version`; updating an existing page should read the page first, edit against the returned `latestVersionNumber`, and pass that number as `--base-version`. A stale base version returns the server's 409 conflict payload so scripts can re-read, merge, and retry. + The default help fetches `/api/client` once and renders the server-owned command syntax, descriptions, examples, and operation routes available to the selected bearer session. Authentication and generic transport flags stay local bootstrap behavior; the product command surface belongs to the Worker so API changes do not require a CLI release just to discover or invoke new operations. The profile file is intentionally compatible with the prior Node CLI: diff --git a/internal/craken/command_test.go b/internal/craken/command_test.go index b5b89c7..be19c15 100644 --- a/internal/craken/command_test.go +++ b/internal/craken/command_test.go @@ -441,12 +441,12 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { switch r.Method + " " + r.URL.Path { case "GET /api/workspaces": writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) - case "POST /api/workspaces/workspace-id/wiki/pages": + case "PATCH /api/workspaces/workspace-id/wiki/pages/Home": var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } - if body["title"] != "Home" || body["content"] != "# Home\n" { + if body["title"] != "Home" || body["content"] != "# Home\n" || body["baseVersionNumber"] != float64(12) { t.Fatalf("unexpected wiki body %#v", body) } writeJSON(t, w, map[string]any{"page": body}) @@ -465,7 +465,7 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { })) defer server.Close() - err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--title", "Home", "--content-file", contentPath}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--existing-title", "Home", "--title", "Home", "--content-file", contentPath, "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) if err != nil { t.Fatal(err) } @@ -478,6 +478,38 @@ func TestWikiSaveAndAgentPlanCheck(t *testing.T) { } } +func TestWikiSaveReportsBaseVersionConflict(t *testing.T) { + configDir := t.TempDir() + t.Setenv("CRAKEN_CONFIG_DIR", configDir) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/workspaces": + writeJSON(t, w, map[string]any{"workspaces": []map[string]any{{"id": "workspace-id", "name": "test0"}}}) + case "PATCH /api/workspaces/workspace-id/wiki/pages/Home": + w.WriteHeader(http.StatusConflict) + writeJSON(t, w, map[string]any{ + "conflict": map[string]any{ + "currentVersionNumber": 13, + "receivedBaseVersionNumber": 12, + "reason": "stale_base_version", + }, + "error": "Wiki page update for \"Home\" conflicted: baseVersionNumber 12 does not match current version 13.", + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := Run(context.Background(), "dev", []string{"wiki", "save", "--token", "test-token", "--base-url", server.URL, "--workspace", "test0", "--existing-title", "Home", "--content", "stale", "--base-version", "12"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected stale wiki save to fail") + } + if message := err.Error(); !strings.Contains(message, "failed with 409") || !strings.Contains(message, "stale_base_version") { + t.Fatalf("expected 409 conflict details, got %q", message) + } +} + func TestFileUploadUsesMultipartAndFolderCreate(t *testing.T) { configDir := t.TempDir() t.Setenv("CRAKEN_CONFIG_DIR", configDir) diff --git a/internal/craken/util.go b/internal/craken/util.go index 7bfbd24..b3d03aa 100644 --- a/internal/craken/util.go +++ b/internal/craken/util.go @@ -120,6 +120,18 @@ func numberOption(cmd command, name string, fallback int) (int, error) { return parsed, nil } +func positiveNumberOption(cmd command, name string) (*int, error) { + value := cmd.string(name, "") + if value == "" { + return nil, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 { + return nil, fmt.Errorf("expected positive integer, got %s", value) + } + return &parsed, nil +} + func boolOption(cmd command, name string) bool { return cmd.Flags[name] || cmd.string(name, "") != "" } diff --git a/internal/craken/wiki_agent.go b/internal/craken/wiki_agent.go index 9be2ae6..f7f4c52 100644 --- a/internal/craken/wiki_agent.go +++ b/internal/craken/wiki_agent.go @@ -35,14 +35,25 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) if err != nil { return err } + baseVersionNumber, err := positiveNumberOption(cmd, "base-version") + if err != nil { + return err + } if existing := cmd.string("existing-title", ""); existing != "" { - return printClientJSON(ctx, client, stdout, "PATCH", wikiPagePath(workspaceID, existing), compact(map[string]any{"content": content, "title": cmd.string("title", existing)})) + return printClientJSON( + ctx, + client, + stdout, + "PATCH", + wikiPagePath(workspaceID, existing), + wikiSaveBody(content, cmd.string("title", existing), baseVersionNumber), + ) } title, err := cmd.required("title") if err != nil { return err } - return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/wiki/pages", map[string]any{"content": content, "title": title}) + return printClientJSON(ctx, client, stdout, "POST", workspacePath(workspaceID)+"/wiki/pages", wikiSaveBody(content, title, baseVersionNumber)) case "delete": title, err := cmd.required("title", "page") if err != nil { @@ -89,6 +100,14 @@ func runWiki(ctx context.Context, client *client, cmd command, stdout io.Writer) } } +func wikiSaveBody(content string, title string, baseVersionNumber *int) map[string]any { + body := map[string]any{"content": content, "title": title} + if baseVersionNumber != nil { + body["baseVersionNumber"] = *baseVersionNumber + } + return body +} + func runAgent(ctx context.Context, client *client, cmd command, stdout io.Writer) error { if cmd.Action == "plan-check" { previous, err := jsonOption(cmd, "previous-json", "previous-file", false)