diff --git a/STYLE.md b/STYLE.md index 19069087..fcbde6be 100644 --- a/STYLE.md +++ b/STYLE.md @@ -30,7 +30,9 @@ or hex elsewhere. The tone table (`internal/ui/ui.go`) maps each role: | Error βœ– | `Errorf` (`toneErr`) | red `#f64c4c` | bold glyph | | Label : value | `Field`, `Stat` (`toneLabel`) | dim neutral | β€” | -**No emoji.** The lime `●` is the online indicator (not 🟒). +**Emoji are welcome** β€” used with intent, for warmth (πŸ‘‹ greeting, πŸ’š sign-off, +πŸš€ sent) and for status (🟒 online, 🟑 starting, πŸ”΄ offline, ⚠ caution). They're a +brand touch, not policed by the guard β€” just don't overuse them. The engine renders exact 24-bit hex on truecolor terminals, the **deep shade** (`#01637a` / `#578c2b`) on light backgrounds, the nearest ANSI-16 otherwise, and @@ -38,6 +40,26 @@ nothing when colour is off (`NO_COLOR` / non-TTY / `TERM=dumb` / `--plain`). The exact brand SGR is pinned by `internal/ui/brand_tones_test.go`, so a drift in the tone table fails CI. The installer mirrors this in `scripts/lib/common.sh`. +## Guided-prompt spacing + +Interactive flows (the `tb data ingest` questionnaire, and any future guided +flow) use one uniform rhythm so every question reads the same: + +- **One blank line before each question header** β€” a `Step N of M Β· ` + (`PromptStep`) or an unnumbered refinement/confirm header (`Section`). The + header method emits this leading blank itself. +- **One blank line between the header and its supporting text** (the hint / + examples / option list), when there is any. +- **One blank line before the `?` prompt line.** With no supporting text, that + single blank sits directly between the header and the prompt. +- **A result that belongs to an answer attaches to it with no blank** β€” e.g. the + `βœ” Found a CSV table …` sniff echo sits directly under the path answer. + +So: `header β†’ blank β†’ [supporting text β†’ blank] β†’ ? prompt`. The prompt line is +answer-only (`? train`); the question lives in the header (the prompter runs +`bare`), never repeated on the `?` line. Keep it uniform β€” don't hand-tune the +spacing of individual questions. + ## Terminology Source of truth: the docs repo `TERMINOLOGY.md`. In user-facing output: @@ -58,8 +80,8 @@ word in output text only. ## What's enforced vs reviewed `scripts/check-style.sh` (CI Lint job, blocking) catches the **mechanical** -violations: hardcoded brand colour outside `internal/ui`, status emoji, and -`workspace` in user-facing text. Run it locally with `make check-style` (also part +violations: hardcoded brand colour outside `internal/ui`, and `workspace` in +user-facing text. Run it locally with `make check-style` (also part of `make ci`) or directly: `bash scripts/check-style.sh`. It can't police **judgement** β€” using the right *role* for a token (a command in diff --git a/internal/cli/copy_catalog_test.go b/internal/cli/copy_catalog_test.go new file mode 100644 index 00000000..433fabde --- /dev/null +++ b/internal/cli/copy_catalog_test.go @@ -0,0 +1,595 @@ +package cli + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/tracebloc/cli/internal/doctor" + "github.com/tracebloc/cli/internal/push" + "github.com/tracebloc/cli/internal/submit" + "github.com/tracebloc/cli/internal/ui" +) + +// TestCopyCatalog generates the copy catalog under testdata/golden/ β€” ONE file +// per command, so every user-facing string can be reviewed a screen at a time +// without deploying. Each file leads with what you see when you RUN the command +// (byte-exact, colour off), covers every state/path we can render deterministically, +// and ends with the command's `--help`. Copy that only appears mid-flow (ingest +// steps + progress, the login device flow, delete confirmation, and every +// failure remedy β€” many of which embed the launcher name, which varies by +// install) can't be pinned as a stable screen; zz-all-strings.golden is the +// completeness backstop for those β€” every user-facing string in the source. +// +// The test fails on drift; regenerate after an intentional copy change: +// +// TB_UPDATE_GOLDEN=1 go test ./internal/cli/ -run TestCopyCatalog +// +// Files are ordered by how often a user hits them (00 = most). Adding a command +// is a single entry in the files map below. +func TestCopyCatalog(t *testing.T) { + bi := BuildInfo{Version: "1.4.4", GitSHA: "0000000", BuildDate: "2026-01-01"} + + // help captures a command's `--help` through the REAL flag path (SetArgs+ + // Execute β€” exactly what the binary runs), byte-exact. + help := func(path ...string) string { + var b bytes.Buffer + r := NewRootCmd(bi) + r.SetOut(&b) + r.SetErr(&b) + r.SetArgs(append(append([]string{}, path...), "--help")) + _ = r.Execute() + return b.String() + } + // rndr renders one screen via the real renderer, colour off. + rndr := func(f func(*ui.Printer)) string { + var b bytes.Buffer + f(ui.New(&b, ui.WithColor(false))) + return b.String() + } + + // doc assembles one command file: a title, a "when you see this" note, then + // labelled `$ cmd` blocks (verbatim output), then the command's --help + // block(s) at the bottom (one command can have several β€” e.g. client has + // create/list/status). + type run struct { + cmd, out string // "$ " then verbatim + } + doc := func(title, whenSeen string, runs []run, helps []run) string { + var s strings.Builder + s.WriteString(title + "\n" + strings.Repeat("=", len([]rune(title))) + "\n") + s.WriteString(whenSeen + "\n") + for _, r := range runs { + s.WriteString("\n$ " + r.cmd + "\n") + s.WriteString(r.out) + } + if len(helps) > 0 { + s.WriteString("\n\n" + strings.Repeat("-", 60) + "\n--help\n" + strings.Repeat("-", 60) + "\n") + for i, h := range helps { + if i > 0 { + s.WriteString("\n") + } + s.WriteString("$ " + h.cmd + "\n") + s.WriteString(h.out) + } + } + return s.String() + } + + // ── 00 home β€” every state resolveHomeModel can produce ────────────────────── + online := homeModel{ + state: homeOnline, email: "lukas@tracebloc.io", name: "Lukas", envName: "hello-world", + compute: computeInfo{CPU: 12, MemGiB: 23}, hasCompute: true, inv: binTB, fullMenu: true, hasResources: true, + } + noComp := online + noComp.state, noComp.hasCompute, noComp.compute = homeRunning, false, computeInfo{} + notOnline := noComp + notOnline.confirmedNotOnline = true + starting := noComp + starting.state = homeStarting + offline := noComp + offline.state = homeOffline + noEnv := noComp + noEnv.state, noEnv.fullMenu, noEnv.envName = homeNoEnv, false, "" + signedOut := homeModel{state: homeNotSignedIn, inv: binTB} + + homeFile := doc( + "tb / tracebloc β€” home", + "What you see when you run `tb` (or `tracebloc`) with no arguments. Covers every\nstate the home view resolves to.", + []run{ + {"tb # signed in Β· secure environment Online", rndr(func(p *ui.Printer) { renderHome(p, online) })}, + {"tb # signed in Β· running, couldn't confirm connection", rndr(func(p *ui.Printer) { renderHome(p, noComp) })}, + {"tb # signed in Β· running, backend reports not online", rndr(func(p *ui.Printer) { renderHome(p, notOnline) })}, + {"tb # signed in Β· starting up", rndr(func(p *ui.Printer) { renderHome(p, starting) })}, + {"tb # signed in Β· offline (can't reach it)", rndr(func(p *ui.Printer) { renderHome(p, offline) })}, + {"tb # signed in Β· no secure environment on this machine", rndr(func(p *ui.Printer) { renderHome(p, noEnv) })}, + {"tb # not signed in", rndr(func(p *ui.Printer) { renderHome(p, signedOut) })}, + }, + []run{{"tracebloc --help", help()}}, + ) + + // ── 01 data ingest β€” stage a dataset (the guided questionnaire) ────────────── + // driveIngest runs the REAL guided flow (runInteractive) with a prompter that + // prints each question as the terminal shows it, so the transcript is every + // prompt in order: intro, the core questions, the family sniff/echo, the task + // picker, the task-specific questions, the review, and the confirm. The intro + // preamble mirrors data_ingest_local.go (its text is drift-guarded by the + // backstop); the temp data dir is normalised to a stable placeholder. + driveIngest := func(dir, shownPath string, answers map[string]string) string { + var b bytes.Buffer + p := ui.New(&b, ui.WithColor(false)) + p.Newline() + p.Para("Ingest datasets to your secure environment.") + p.Hintf("For help: https://docs.tracebloc.io/create-use-case/prepare-dataset") + pr := &catalogPrompter{w: &b, answers: answers} + a := &runDataIngestArgs{} + if err := runInteractive(p, pr, a, false /*taskSet*/); err != nil { + t.Fatalf("driveIngest(%s): %v", dir, err) + } + return strings.ReplaceAll(b.String(), dir, shownPath) + } + tabDir := tabularDir(t) + imgDir := imageDirLayout(t) + txtDir := textDirLayout(t) + tabularIngest := driveIngest(tabDir, "~/data/patients", map[string]string{ + "Do you want to ingest training or test data?": "train", + "Please name the dataset.": "hospital_train", + "Where is your data?": tabDir, + "Which task?": "tabular_classification", + "Which column holds the label?": "churned", + }) + imageIngest := driveIngest(imgDir, "~/data/xray", map[string]string{ + "Do you want to ingest training or test data?": "train", + "Please name the dataset.": "xray_train", + "Where is your data?": imgDir, + "Which task?": "image_classification", + "Which column holds the label?": "label", + "Image resolution": "224x224", + }) + // Text family: text_classification shows the label question; the picker lists + // every text task + blurb. (Self-supervised text β€” masked/causal LM, seq2seq + // β€” skips the label step; that path is covered by the backstop.) + textIngest := driveIngest(txtDir, "~/data/reviews", map[string]string{ + "Do you want to ingest training or test data?": "train", + "Please name the dataset.": "reviews_train", + "Where is your data?": txtDir, + "Which task?": "text_classification", + "Which column holds the label?": "label", + }) + // execIngest renders the run that follows the confirm β€” the three steps and + // the final summary. printLocalSummary + submit.RenderSummary are the REAL + // renderers (drift-caught); the step headers/hints mirror the run + // orchestration (their strings are drift-guarded by zz-all-strings). The raw + // ingestor stream the CLI streams through (MySQL waits, the πŸ“Š banner, + // per-validator lines) is the *ingestor's* stdout β€” not CLI copy β€” so it + // isn't shown: this is the CLI's own view of the run. + execIngest := func() string { + var b bytes.Buffer + p := ui.New(&b, ui.WithColor(false)) + layout := &push.LocalLayout{ + Root: "~/data/patients", + LabelsCSV: "~/data/patients/data.csv", + TotalBytes: 52807, + } + spec := map[string]any{ + "category": "tabular_classification", + "table": "hospital_train", + "intent": "train", + "label": "churned", + "schema": map[string]string{ + "age": "INT", "income": "FLOAT", "tenure": "INT", "balance": "FLOAT", + "products": "INT", "active": "INT", "region": "VARCHAR(16)", + }, + } + p.Step(1, 3, "Check your data") + p.Hintf("Reading your files locally first β€” nothing has touched your secure environment yet β€” so a layout or settings problem shows up right away.") + // Guided path: the Review already echoed the settings, so the duplicate + // "Ingest settings" block is suppressed (showSettings=false). The flag-only + // path passes true; that block's copy is in zz-all-strings.golden. + printLocalSummary(p, layout, spec, false) + p.Step(2, 3, "Copy into your secure environment") + p.Hintf("Your files are copied securely into your secure environment's storage β€” set up and cleaned up for you.") + p.Step(3, 3, "Validate and load") + p.Hintf("Submitting the run, then following along as tracebloc validates your data and loads it into the table β€” progress streams below.") + p.Hintf("This follows the run for up to an hour; a longer run keeps going on its own (or start it with --detach and check back later).") + submit.RenderSummary(p, &submit.Summary{ + IngestorID: "80c224bd-202b-4a6f-9362-61c84599f334", + TotalRecords: 849, + ProcessedRecords: 849, + InsertedRecords: 849, + APISentRecords: 849, + }) + return b.String() + } + dataIngestFile := doc( + "tb data ingest β€” stage a dataset into your secure environment", + "What you see when you run `tb data ingest` with no flags: a short intro, a\nfour-step guided setup (intent, name, path, task) then the task-specific\nquestions, and β€” after you confirm β€” the run itself. The setup is\ndriven through the real flow for one task in each family (tabular, image, text)\nso the task-specific questions are visible; each core question prints as a\n`Step N of 4 Β· …` header, the task-specific ones (the label column, and extras\nlike resolution or schema) as their own header, the\nsupporting line beneath it, and the `?` line shows your answer. The run (shown\nonce, for tabular) is the three steps + the final summary as the CLI renders\nthem. Passing flags (--as, --task, a path, …) skips the matching questions. The\nother tasks' extra questions (keypoints, label policy, time column),\nself-supervised text (which skips the label question), and the failure-summary\nwordings are in zz-all-strings.golden. The raw ingestor stream the CLI streams\nthrough (MySQL waits, the πŸ“Š banner, per-validator lines) is the engine's own\nstdout β€” not CLI copy β€” so it isn't shown. (`tb ingest` is a hidden deprecated\nalias; `push` is a deprecated alias of the verb.)", + []run{ + {"tb data ingest # guided Β· tabular classification", tabularIngest}, + {"tb data ingest # guided Β· image classification", imageIngest}, + {"tb data ingest # guided Β· text classification", textIngest}, + {"tb data ingest # after you confirm β€” the run (tabular)", execIngest()}, + }, + []run{ + {"tracebloc data ingest --help", help("data", "ingest")}, + {"tracebloc data validate --help", help("data", "validate")}, + }, + ) + + // ── 02 data list ──────────────────────────────────────────────────────────── + sample := []push.DatasetInfo{ + {Name: "xray_train", Intent: "train", Task: "image_classification", Records: 12000, Classes: 2, Extension: "jpg", SizeBytes: 1 << 30}, + {Name: "xray_test", Intent: "test", Task: "image_classification", Records: 3000, Classes: 2, Extension: "jpg", SizeBytes: 256 << 20}, + {Name: "ingest_run_journal", System: true}, + } + dataListFile := doc( + "tb data list β€” list your datasets", + "What you see when you run `tb data list`. Covers empty, populated, and --all.", + []run{ + {"tb data list # no datasets yet", rndr(func(p *ui.Printer) { renderDataList(p, "hello-world", nil, false) })}, + {"tb data list # with datasets (system tables hidden)", rndr(func(p *ui.Printer) { renderDataList(p, "hello-world", sample, false) })}, + {"tb data list --all # including system tables", rndr(func(p *ui.Printer) { renderDataList(p, "hello-world", sample, true) })}, + }, + []run{{"tracebloc data list --help", help("data", "list")}}, + ) + + // ── 03 data delete ────────────────────────────────────────────────────────── + dataDeleteFile := doc( + "tb data delete β€” delete a dataset", + "What you see when you run `tb data delete `. The command confirms before\nit deletes; the confirmation prompt, the progress, and the success/failure lines\nstream during the flow (not a stable screen) β€” they're all in zz-all-strings.golden.", + nil, + []run{{"tracebloc data delete --help", help("data", "delete")}}, + ) + + // ── 04 resources ───────────────────────────────────────────────────────────── + resourcesFile := doc( + "tb resources β€” see / change what a training run may use", + "What you see when you run `tb resources`. The view reads live cluster capacity,\nso it isn't a stable screen: it prints \"Your secure environment is equipped\nwith: …\", \"A training run is allocated up to: …\", and a hint to run\n`tb resources set` β€” all indexed in zz-all-strings.golden. `tb resources set` is\na guided walkthrough (prompts stream during the flow; also in the backstop).", + nil, + []run{ + {"tracebloc resources --help", help("resources")}, + {"tracebloc resources set --help", help("resources", "set")}, + }, + ) + + // ── 05 doctor ──────────────────────────────────────────────────────────────── + // doctorRollup mirrors runDoctor's rollup tail (doctor.go ~197-224) using the + // REAL summarizeDoctor + renderHealth + doctorVerdict, so this copy is + // drift-caught. Only launcher-free rollups are rendered here β€” the failure + // lines ("Not connected β€” …", "Not ready β€” …") and their remedies embed the + // launcher name (tb vs tracebloc, install-dependent) and so are catalogued in + // zz-all-strings.golden instead. + doctorRollup := func(p *ui.Printer, email, envName string, results []doctor.Result, tok tokenState) { + p.Para("Signed in as " + email) + p.Para(fmt.Sprintf("Secure environment %q", envName)) + connected, ready := summarizeDoctor(results, tok) + p.Newline() + renderHealth(p, connected) + renderHealth(p, ready) + p.Newline() + fail, allGood := doctorVerdict(connected.status, ready.status) + switch { + case fail: + p.Hintf("Still stuck? Email support@tracebloc.io with the output of `%s doctor --diagnose`.", launcher()) + case allGood: + p.Successf("Everything looks good β€” you're ready to run training.") + default: + p.Infof("No problems found, but some checks couldn't finish β€” re-run with --verbose for detail.") + } + } + // Connected + readiness unknown: Pod health warns with a list failure, which + // summarizeDoctor maps to an honest "couldn't check your workloads". + cantCheck := []doctor.Result{{Name: "Pod health", Status: doctor.StatusWarn, Detail: "could not list pods: forbidden"}} + doctorFile := doc( + "tb doctor β€” is my secure environment healthy?", + "What you see when you run `tb doctor`. The two rollup lines (Connected, Ready)\nplus a verdict are shown below for the healthy and the can't-fully-check cases.\nThe failure variants (Not connected β€” …, Not ready β€” …) and their remedies vary\nwith the reachability classification and embed the launcher name, so the full set\nis indexed in zz-all-strings.golden. --verbose adds a Kubernetes breakdown\n(context/server/namespace + each granular check); those strings are in the\nbackstop too.", + []run{ + {"tb doctor # healthy", rndr(func(p *ui.Printer) { doctorRollup(p, "lukas@tracebloc.io", "hello-world", nil, tokenOK) })}, + {"tb doctor # connected, but a check couldn't complete (e.g. RBAC)", rndr(func(p *ui.Printer) { doctorRollup(p, "lukas@tracebloc.io", "hello-world", cantCheck, tokenOK) })}, + }, + []run{ + {"tracebloc doctor --help", help("doctor")}, + {"tracebloc cluster doctor --help", help("cluster", "doctor")}, + }, + ) + + // ── 06 delete (offboard this machine) ───────────────────────────────────────── + deleteFile := doc( + "tb delete β€” remove tracebloc from this machine", + "What you see when you run `tb delete`. The pre-flight summary (below) is shown\nbefore you confirm, for both keep-data and remove-data. The confirmation prompt\nand the teardown progress stream during the flow β€” those strings are in\nzz-all-strings.golden.", + []run{ + {"tb delete # summary Β· keep my data", rndr(func(p *ui.Printer) { renderOffboardSummary(p, "lukas-macbook", true) })}, + {"tb delete --remove-data # summary Β· remove my data too", rndr(func(p *ui.Printer) { renderOffboardSummary(p, "lukas-macbook", false) })}, + }, + []run{{"tracebloc delete --help", help("delete")}}, + ) + + // ── 07 login / logout / auth ────────────────────────────────────────────────── + loginFile := doc( + "tb login / logout β€” sign in and out", + "What you see when you run `tb login`. Sign-in is a device flow: the CLI prints an\n\"Open \" line and an \"Enter \" line, waits, then confirms β€” that copy\nstreams during the flow (not a stable screen), so it's in zz-all-strings.golden.\n`tb auth status` reports who you're signed in as.", + nil, + []run{ + {"tracebloc login --help", help("login")}, + {"tracebloc logout --help", help("logout")}, + {"tracebloc auth status --help", help("auth", "status")}, + }, + ) + + // ── 08 client ────────────────────────────────────────────────────────────────── + clientFile := doc( + "tb client β€” register / list / inspect environments", + "What you see under `tb client`. `tb client create` shows a review (below) before\nit registers a new secure environment. `tb client list` / `tb client status` read\nlive backend state, so they aren't stable screens β€” their strings are in\nzz-all-strings.golden.", + []run{ + {"tb client create # review, before you confirm", rndr(func(p *ui.Printer) { renderClientReview(p, "lukas-macbook", "lukas-macbook", "DE", "a1b2c3d4") })}, + }, + []run{ + {"tracebloc client --help", help("client")}, + {"tracebloc client create --help", help("client", "create")}, + {"tracebloc client list --help", help("client", "list")}, + {"tracebloc client status --help", help("client", "status")}, + }, + ) + + // ── 09 cluster ─────────────────────────────────────────────────────────────── + clusterFile := doc( + "tb cluster β€” low-level cluster info", + "What you see under `tb cluster`. `tb cluster info` reads live cluster state, so\nit isn't a stable screen; its strings are in zz-all-strings.golden. (`tb cluster\ndoctor` is the same health check as `tb doctor` β€” see 05-doctor.)", + nil, + []run{ + {"tracebloc cluster --help", help("cluster")}, + {"tracebloc cluster info --help", help("cluster", "info")}, + }, + ) + + // ── 10 version ─────────────────────────────────────────────────────────────── + versionFile := doc( + "tb version β€” print the CLI version", + "What you see when you run `tb version`. It prints one line:\n\n tracebloc (, built , on /)\n\nThe go-version and os/arch are filled in at runtime, so the exact line varies by\nmachine (that's why it isn't pinned byte-exact here). `--output-json` emits the\nsame fields as indented JSON. Only the --help is byte-exact below.", + nil, + []run{{"tracebloc version --help", help("version")}}, + ) + + files := map[string]string{ + "00-home.golden": homeFile, + "01-data-ingest.golden": dataIngestFile, + "02-data-list.golden": dataListFile, + "03-data-delete.golden": dataDeleteFile, + "04-resources.golden": resourcesFile, + "05-doctor.golden": doctorFile, + "06-delete.golden": deleteFile, + "07-login.golden": loginFile, + "08-client.golden": clientFile, + "09-cluster.golden": clusterFile, + "10-version.golden": versionFile, + "zz-all-strings.golden": "every user-facing string in the source (AST-harvested β€” all arguments to the\n" + + "Printer methods + errors.New/fmt.Errorf/fmt.Sprintf, plus the text/remedy\n" + + "fields of healthLine{} and doctor.Result{} literals, both \"…\" and `…` raw\n" + + "strings). The completeness backstop: catches the failure remedies and the\n" + + "multi-step flows (ingest steps, login, progress, confirmations) not shown as a\n" + + "screen. %s/%d are runtime placeholders.\n\n" + strings.Join(quoteAll(harvestMessages(t)), "\n") + "\n", + } + + // Coverage guarantee: a command's PRIMARY PATH must be rendered as a screen, + // not left to the string backstop β€” so a dropped/half-rendered flow fails the + // test instead of silently vanishing from the catalog (which is how the whole + // ingest execution went missing once). Each entry names markers that must + // appear in that file's rendered content; add one when a file gains a phase. + mustRender := map[string][]string{ + "00-home.golden": {"tracebloc --help"}, + "01-data-ingest.golden": { + "Ingest datasets to your secure environment.", // intro + "Step 1 of 4 Β·", "Step 4 of 4 Β·", // the guided questionnaire + "Review", "Proceed with the ingest?", // review + confirm + "Step 1/3", "Step 3/3", "Ingestion summary", "What's next", // the run + }, + "02-data-list.golden": {"tracebloc data list --help"}, + "05-doctor.golden": {"Connected to tracebloc", "Everything looks good"}, + } + for name, needles := range mustRender { + got := files[name] + for _, n := range needles { + if !strings.Contains(got, n) { + t.Errorf("%s is missing required primary-path copy %q β€” a screen may have been dropped or half-rendered", name, n) + } + } + } + + update := os.Getenv("TB_UPDATE_GOLDEN") != "" + for name, content := range files { + path := filepath.Join("testdata/golden", name) + if update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + continue + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s (regenerate: TB_UPDATE_GOLDEN=1 go test ./internal/cli/ -run TestCopyCatalog): %v", path, err) + } + if content != string(want) { + t.Errorf("%s drifted. Regenerate + review the diff:\n TB_UPDATE_GOLDEN=1 go test ./internal/cli/ -run TestCopyCatalog", path) + } + } + if update { + t.Logf("wrote %d catalog files under testdata/golden/", len(files)) + } +} + +func quoteAll(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strconv.Quote(s) + } + return out +} + +// catalogPrompter is the prompter seam's catalog double: it prints each question +// the way the terminal shows it ("? ") and returns a scripted +// answer, so driving the REAL runInteractive produces a byte-exact transcript of +// the guided flow β€” every prompt, in order. The description line above each +// question is the real p.PromptHint in runInteractive; only the "? …" line is +// rendered here (survey draws it in production). +type catalogPrompter struct { + w *bytes.Buffer + answers map[string]string +} + +func (c *catalogPrompter) pick(label, def string) string { + if a, ok := c.answers[label]; ok { + return a + } + return def +} + +// answerLine renders the input line the way the bare surveyPrompter does for the +// guided flow: the question is already printed by the CLI (PromptStep/Section), +// so the prompt shows only "? " (or "?" on a blank/accept-default). +func (c *catalogPrompter) answerLine(ans string) { + if ans == "" { + fmt.Fprintf(c.w, "?\n") + return + } + fmt.Fprintf(c.w, "? %s\n", ans) +} + +func (c *catalogPrompter) Input(label, _, def string, _ func(string) error) (string, error) { + ans := c.pick(label, def) + c.answerLine(ans) + return ans, nil +} + +func (c *catalogPrompter) Select(label, _ string, _ []string, def string) (string, error) { + ans := c.pick(label, def) + c.answerLine(ans) + return ans, nil +} + +// Confirm keeps its label (never bare β€” see surveyPrompter.Confirm): a y/N +// prompt has no header of its own, so survey draws "? ". +// Mirror that here so the catalog shows the confirm question. +func (c *catalogPrompter) Confirm(label string, def bool) (bool, error) { + ans := "No" + if def { + ans = "Yes" + } + fmt.Fprintf(c.w, "? %s %s\n", label, ans) + return def, nil +} + +// harvestMessages parses the user-facing packages and returns every string +// literal that reaches a user: ALL arguments to a Printer method or an error / +// format constructor (errors.New, fmt.Errorf, fmt.Sprintf), PLUS the string +// fields of healthLine{} and doctor.Result{} composite literals β€” those carry +// user-facing text (the doctor rollup lines, check details + remedies) that is +// never passed to a Printer call, so an arguments-only harvest would miss it. +// Both "…" and `…` raw strings; deduped + sorted. +func harvestMessages(t *testing.T) []string { + t.Helper() + methods := map[string]bool{ + "Successf": true, "Warnf": true, "Errorf": true, "Infof": true, "Hintf": true, + "Detailf": true, "Para": true, "Section": true, "PromptHint": true, "PromptHeader": true, + "PromptStep": true, + "WarnLine": true, "CrossLine": true, "CheckLine": true, "Step": true, "Action": true, + "Stat": true, "Field": true, "MenuRow": true, "Banner": true, "Command": true, + // prompter seam (survey) β€” question labels + help text for every guided + // flow (ingest, client create, delete), incl. flows not driven as a screen. + "Input": true, "Select": true, "Confirm": true, + } + isCopyCall := func(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + if methods[sel.Sel.Name] { + return true + } + if x, ok := sel.X.(*ast.Ident); ok { + return (x.Name == "errors" && sel.Sel.Name == "New") || + (x.Name == "fmt" && (sel.Sel.Name == "Errorf" || sel.Sel.Name == "Sprintf")) + } + return false + } + // healthLine{} (this package) and doctor.Result{} (this package as + // doctor.Result, the doctor package as a bare Result) hold user-facing text + // in struct fields, not call arguments. + isCopyStruct := func(t ast.Expr) bool { + switch tt := t.(type) { + case *ast.Ident: + return tt.Name == "healthLine" || tt.Name == "Result" + case *ast.SelectorExpr: + return tt.Sel.Name == "Result" + } + return false + } + + seen := map[string]struct{}{} + collect := func(exprs []ast.Expr) { + for _, arg := range exprs { + lit, ok := arg.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + s, uerr := strconv.Unquote(lit.Value) + if uerr != nil { + continue + } + s = strings.TrimSpace(s) + // Skip empties and format-only fragments (e.g. "%s", " ") β€” no words. + if len([]rune(s)) < 4 || !strings.ContainsAny(s, "abcdefghijklmnopqrstuvwxyz") { + continue + } + seen[s] = struct{}{} + } + } + fset := token.NewFileSet() + for _, dir := range []string{".", "../submit", "../push", "../doctor", "../cluster"} { + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + f, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return nil + } + ast.Inspect(f, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CallExpr: + if isCopyCall(node) { + collect(node.Args) + } + case *ast.CompositeLit: + if isCopyStruct(node.Type) { + for _, el := range node.Elts { + if kv, ok := el.(*ast.KeyValueExpr); ok { + collect([]ast.Expr{kv.Value}) + } else { + collect([]ast.Expr{el}) + } + } + } + } + return true + }) + return nil + }) + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + sort.Strings(out) + return out +} diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index f07e4612..ac6dd7db 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -53,7 +53,7 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) { // TestPrintClusterSummary_VerboseOnly). The local summary shows regardless. var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false), ui.WithVerbose(true)) - printLocalSummary(p, layout, spec) + printLocalSummary(p, layout, spec, true) printClusterSummary(p, release, pvc) out := buf.String() diff --git a/internal/cli/data_ingest_cmd.go b/internal/cli/data_ingest_cmd.go index f4292afd..0f3d8543 100644 --- a/internal/cli/data_ingest_cmd.go +++ b/internal/cli/data_ingest_cmd.go @@ -203,7 +203,9 @@ Exit codes: interactive := !noInput && !outputJSON && isInteractiveTTY() var pr prompter if interactive { - pr = surveyPrompter{} + // bare: the guided flow prints each question as a step header + // (PromptStep), so the prompt line itself stays answer-only. + pr = surveyPrompter{bare: true} } // In --output-json mode, human output goes to stderr so // stdout carries only the JSON result. @@ -345,6 +347,14 @@ type runDataIngestArgs struct { Prompter prompter TaskSet bool + // ReviewShown records whether the guided flow rendered the pre-confirm + // Review (it only does when it actually prompted for something). It gates + // the duplicate "Ingest settings" block in printLocalSummary: suppress it + // only when the Review already showed those fields. A fully flagged run β€” + // even on a TTY with a Prompter set β€” prompts nothing, shows no Review, so + // it must still print the settings once (like the non-interactive path). + ReviewShown bool + // ChangedFlags records which CLI flags were EXPLICITLY set // (cmd.Flags().Changed), decoupling "was it passed" from "is its value // non-zero" β€” the value alone can't tell `--number-of-keypoints 0` (an diff --git a/internal/cli/data_ingest_local.go b/internal/cli/data_ingest_local.go index e29f12cb..a4a394a5 100644 --- a/internal/cli/data_ingest_local.go +++ b/internal/cli/data_ingest_local.go @@ -93,12 +93,8 @@ func resolveLocalInput(out, errOut io.Writer, a *runDataIngestArgs) (layout *pus } a.Printer.Newline() - a.Printer.Para(strings.TrimSpace(` -This ingests a dataset so models can train on it. Your files never leave your -own infrastructure β€” tracebloc copies them into your secure environment's storage, -checks them, and loads them into a table your training runs read from. Other -collaborators can train against that table without ever seeing the raw files.`)) - a.Printer.Hintf("Learn more: https://docs.tracebloc.io") + a.Printer.Para("Ingest datasets to your secure environment.") + a.Printer.Hintf("For help: https://docs.tracebloc.io/create-use-case/prepare-dataset") // 0. Guided mode: prompt for any missing core inputs before // validation. Flags already provided win; non-TTY / --no-input @@ -484,15 +480,26 @@ collaborators can train against that table without ever seeing the raw files.`)) return nil, nil, nil, false, perr } - printLocalSummary(a.Printer, layout, spec) + // Interactive runs that prompted already echoed name/task/intent/label in + // the pre-confirm Review, so suppress the duplicate "Ingest settings" block + // here. A run that showed no Review (non-interactive, OR a fully flagged + // run on a TTY) still shows it β€” gate on whether the Review rendered, not on + // whether a Prompter exists. + printLocalSummary(a.Printer, layout, spec, !a.ReviewShown) return layout, spec, specBytes, false, nil } -// printLocalSummary shows what the CLI found on disk plus the ingest -// settings it assembled β€” the detail under step 1 ("Check your data"). -// Mirrors `cluster info`'s section/Field layout. -func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string]any) { +// printLocalSummary shows what the CLI found on disk plus (for the flag-only +// path) the ingest settings it assembled β€” the detail under step 1 ("Check your +// data"). Mirrors `cluster info`'s section/Field layout. +// +// showSettings gates the "Ingest settings" block: when the guided flow already +// showed those exact fields in its pre-confirm Review, repeating them here is +// noise β€” the caller passes false in that case. Any run without a Review (a +// non-interactive run, or a fully flagged run on a TTY that prompted nothing) +// passes true, so those users still see the resolved settings once. +func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string]any, showSettings bool) { cat, _ := spec["category"].(string) p.Section("Local dataset") @@ -527,6 +534,10 @@ func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string] } p.Field("total size", push.HumanBytes(layout.TotalBytes)) + if !showSettings { + return + } + p.Section("Ingest settings") p.Field("name", fmt.Sprintf("%v", spec["table"])) p.Field("task", fmt.Sprintf("%v", spec["category"])) diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index a4ceb419..257f564d 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -589,7 +589,7 @@ func TestPrintLocalSummary_ShowsDetectedExtension(t *testing.T) { "table": "t", "category": "image_classification", "intent": "train", "spec": map[string]any{"file_options": map[string]any{"extension": ".png"}}, } - printLocalSummary(p, layout, spec) + printLocalSummary(p, layout, spec, true) if !strings.Contains(buf.String(), "1 files (.png)") { t.Errorf("summary missing detected extension:\n%s", buf.String()) } diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index c52f23af..1c56c729 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -38,11 +38,24 @@ type prompter interface { // surveyPrompter is the production prompter, backed by // AlecAivazis/survey/v2 against the real terminal. -type surveyPrompter struct{} +// +// bare drops the question text from the prompt line (survey's Message), for +// flows where the CLI already prints the question itself as a step header +// (the guided ingest flow, via PromptStep) β€” so the prompt reads "? " +// with no duplicate question. Confirm always keeps its label (a short y/n with +// no header of its own). +type surveyPrompter struct{ bare bool } + +func (s surveyPrompter) message(label string) string { + if s.bare { + return "" + } + return label +} -func (surveyPrompter) Input(label, help, def string, validate func(string) error) (string, error) { +func (s surveyPrompter) Input(label, help, def string, validate func(string) error) (string, error) { var ans string - q := &survey.Input{Message: label, Help: help, Default: def} + q := &survey.Input{Message: s.message(label), Help: help, Default: def} var opts []survey.AskOpt if validate != nil { // survey hands the validator the raw answer as interface{}; @@ -58,16 +71,20 @@ func (surveyPrompter) Input(label, help, def string, validate func(string) error return ans, nil } -func (surveyPrompter) Select(label, help string, options []string, def string) (string, error) { +func (s surveyPrompter) Select(label, help string, options []string, def string) (string, error) { var ans string - q := &survey.Select{Message: label, Help: help, Options: options, Default: def} + q := &survey.Select{Message: s.message(label), Help: help, Options: options, Default: def} if err := survey.AskOne(q, &ans); err != nil { return "", mapErr(err) } return ans, nil } -func (surveyPrompter) Confirm(label string, def bool) (bool, error) { +func (s surveyPrompter) Confirm(label string, def bool) (bool, error) { + // Confirm always keeps its label (never bare): a y/N prompt has no step + // header of its own, and the overwrite-replace confirm fires later, during + // the cluster phase, with nothing printed before it β€” a bare "? (y/N)" + // there would be a label-less destructive prompt. ans := def if err := survey.AskOne(&survey.Confirm{Message: label, Default: def}, &ans); err != nil { return false, mapErr(err) @@ -104,14 +121,29 @@ func isInteractiveTTY() bool { // // Mutates a through the pointer. func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bool) error { - p.PromptHeader("Let's set up your data ingest") - p.Hintf("Press Enter to accept a default; Ctrl-C to cancel.") prompted := false - // (a) intent β€” the first thing to settle: what this data is for. + // The guided flow is a four-step setup: intent β†’ name β†’ path β†’ task. Each + // question prints as its own step header (PromptStep), with any supporting + // line beneath it and an answer-only prompt (the prompter runs bare β€” see + // surveyPrompter). Everything task-dependent comes AFTER the four steps as + // unnumbered refinements (Section headers): the label column, and per-task + // extras like schema, resolution, keypoints. These aren't numbered because + // which ones apply β€” and whether any apply at all β€” depends on the task + // picked at step 4 (self-supervised text has no label and no extras), so a + // fixed "of N" couldn't be honest about them. + // + // Spacing is uniform (STYLE.md "Guided-prompt spacing"): the header carries + // its own leading blank; then one blank line, the optional supporting text, + // one blank line, and the `?` prompt. With no supporting text the single + // blank sits directly between header and prompt. A result that belongs to an + // answer (the sniff echo) attaches to it with no blank. + + // Step 1 β€” intent: what this data is for. if a.Spec.Intent == "" { - p.PromptHint("Whether this split trains the model or evaluates it.") - ans, err := pr.Select("Is this training or test data?", "which split this data is", + p.PromptStep(1, 4, "Do you want to ingest training or test data?") + p.Newline() + ans, err := pr.Select("Do you want to ingest training or test data?", "which split this data is", []string{"train", "test"}, "train") if err != nil { return err @@ -120,12 +152,13 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo prompted = true } - // (b) name β€” no auto-fill: the example lives in the hint, so the user - // types their own name rather than editing a pre-filled default. + // Step 2 β€” name. No auto-fill; the character rules surface only if the + // name is rejected (see ValidateTableName), so the prompt stays clean. if a.Spec.Table == "" { - p.PromptHint("A name for this dataset β€” you'll reference it by this name when you start a training run. Start with a letter or underscore, then letters, digits, underscores. e.g. churn_train") - ans, err := pr.Input("What should we call this dataset?", - "MySQL identifier + PVC subdir; start with a letter or underscore, then letters, digits, underscore", "", + p.PromptStep(2, 4, "Please name the dataset.") + p.Newline() + ans, err := pr.Input("Please name the dataset.", + "letters, digits, and underscores; start with a letter or underscore e.g. churn_train", "", push.ValidateTableName) if err != nil { return err @@ -134,10 +167,17 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo prompted = true } - // (c) path β€” then detect the family from the layout and echo it back. + // Step 3 β€” path. Show what "file or folder" means per modality, then + // detect the family from the layout and echo it back. if a.LocalPath == "" { - p.PromptHint("The file or folder holding your data β€” a single .csv for a table, or labels.csv + an images/ folder for images. e.g. ~/datasets/churn") - ans, err := pr.Input("Where is your data? (file or folder)", "e.g. ./my-data", "", validateDatasetPath) + p.PromptStep(3, 4, "Where is your data?") + p.Newline() + p.Hintf("Give the path to a file or a folder β€” whichever holds your data:") + p.Infof("Tabular one CSV file e.g. ~/data/patients.csv") + p.Infof("Images a folder with labels.csv + images/ e.g. ~/data/xray/") + p.Infof("Text a folder with labels.csv + texts/ e.g. ~/data/reviews/") + p.Newline() + ans, err := pr.Input("Where is your data?", "e.g. ~/data/patients.csv or ~/data/xray/", "", validateDatasetPath) if err != nil { return err } @@ -191,6 +231,11 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // β€” an ingest fully specified by flags (on a TTY) isn't nagged. if prompted { renderReview(p, a) + a.ReviewShown = true + // No header here: Confirm keeps its own label ("Proceed with the + // ingest?"), so a Section would just duplicate it. One blank line for + // breathing room between the Review block and the y/N prompt. + p.Newline() ok, err := pr.Confirm("Proceed with the ingest?", true) if err != nil { return err @@ -219,7 +264,10 @@ func resolveFamily(p *ui.Printer, pr prompter, path string) (push.Family, error) // user what looks off so they can fix the layout. p.Warnf("%s", s.Hint) } - p.PromptHint("We couldn't tell the data type from what's there β€” which is it?") + p.Section("What kind of data is this?") + p.Newline() + p.Hintf("We couldn't tell from the layout β€” tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/.") + p.Newline() opts := push.FamilyNouns() ans, err := pr.Select("What kind of data is this?", "tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/", @@ -250,38 +298,51 @@ func pickTask(p *ui.Printer, pr prompter, fam push.Family) (string, error) { return "", fmt.Errorf("no CLI-supported tasks for %s data yet", push.FamilyNoun(fam)) } - p.Section(fmt.Sprintf("Tasks for %s data", push.FamilyNoun(fam))) - p.Hintf("Available now:") + // Align the task IDs into a column so the blurbs line up, sized to the + // longest ID shown (available + pending). + width := 0 + for _, s := range available { + if len(s.ID) > width { + width = len(s.ID) + } + } + for _, s := range pending { + if len(s.ID) > width { + width = len(s.ID) + } + } + width += 3 + + p.PromptStep(4, 4, "What kind of machine learning task is this data for?") + p.Newline() for _, s := range available { - p.Infof("%s β€” %s Β· %s", s.DisplayName(), s.Blurb, s.ID) + p.Para(fmt.Sprintf(" %-*s%s", width, s.ID, s.Blurb)) } if len(pending) > 0 { + p.Newline() p.Hintf("Not yet in the CLI:") for _, s := range pending { - p.Hintf(" %s β€” %s Β· %s (%s)", s.DisplayName(), s.Blurb, s.ID, s.UnsupportedNote) + p.Hintf(" %-*s%s (%s)", width, s.ID, s.Blurb, s.UnsupportedNote) } } + p.Newline() + // The options ARE task IDs (what the list shows and what the user picks), + // so the answer is the category directly. Guard an unexpected answer by + // falling back to the first available β€” never return an empty category. opts := make([]string, len(available)) for i, s := range available { - opts[i] = s.DisplayName() + opts[i] = s.ID } ans, err := pr.Select("Which task?", "pick the task this data is for", opts, opts[0]) if err != nil { return "", err } - // Map the answer back to a task by POSITION, not through a - // DisplayNameβ†’ID map: two tasks in a family could in principle share a - // display name, and a map would silently keep only the last, returning the - // wrong ID for the first. Matching the offered option by index returns the - // one the user actually saw (the list above is in this same order). - for i, o := range opts { - if o == ans { - return available[i].ID, nil + for _, id := range opts { + if id == ans { + return ans, nil } } - // Defensive: an answer that isn't one of the offered options. Never return - // an empty category β€” fall back to the first available. return available[0].ID, nil } @@ -294,19 +355,27 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b cat := a.Spec.Category prompted := false - // Label column β€” the answer the model learns to produce. Skipped for - // self-supervised text (MLM/CLM: the target comes from the text itself, - // there's no label column). Interactive picks from the REAL CSV header - // row so the choice exact-matches a column that exists β€” killing the - // case-mismatch silent-null-label class (data-ingestors#340) that - // free-typing "Label" against a "label" header would cause. Wording is - // per-task: a class to sort into vs a numeric value to predict (Β§8). + // Label column β€” the answer the model learns to produce. The first + // task-specific refinement (unnumbered Section, like the extras below), not + // a numbered core step: it's skipped for self-supervised text (MLM/CLM: the + // target comes from the text itself, there's no label column), so numbering + // it "of N" would promise a step that flow never reaches. Interactive picks + // from the REAL CSV header row so the choice exact-matches a column that + // exists β€” killing the case-mismatch silent-null-label class + // (data-ingestors#340) that free-typing "Label" against a "label" header + // would cause. Wording is per-task: a class to sort into vs a numeric value + // to predict (Β§8). if !push.SelfSupervisedText(cat) && a.Spec.LabelColumn == "" { - question := "Which column holds the class?" + question := "Which column holds the label?" + desc := "The answer the model learns to produce β€” for classification, the class. e.g. diagnosis, churned" if push.IsRegressionClass(cat) { question = "Which column holds the value to predict?" + desc = "The number the model learns to predict. e.g. price, age, days_to_event" } - p.PromptHint("The column in your CSV with the answer the model learns to produce.") + p.Section(question) + p.Newline() + p.Hintf("%s", desc) + p.Newline() ans, err := promptLabelColumn(pr, cat, a.LocalPath, question) if err != nil { return prompted, err @@ -315,11 +384,17 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b prompted = true } + // Further task-specific refinements β€” like the label above, each gets its + // own Section header rather than a step number, since which ones appear + // depends on the task. switch { case push.IsImage(cat): if cat == "keypoint_detection" && a.Spec.NumberOfKeypoints <= 0 { - p.PromptHint("How many keypoints each sample is annotated with β€” dataset-specific, no default. e.g. 17 for COCO human pose") - ans, err := pr.Input("Number of keypoints per sample", + p.Section("How many keypoints per sample?") + p.Newline() + p.Hintf("The number of landmark points each sample is annotated with β€” dataset-specific. e.g. 17 for COCO human pose") + p.Newline() + ans, err := pr.Input("How many keypoints per sample?", "e.g. 17 for COCO pose", "", validatePositiveInt) if err != nil { return prompted, err @@ -329,8 +404,11 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b prompted = true } if a.TargetSizeFlag == "" { - p.PromptHint("The resolution your images already are. tracebloc never resizes β€” it checks every image is exactly this size and rejects any that differ. Blank = read it from your first image. e.g. 224x224") - ans, err := pr.Input("Image resolution as WxH (blank = read it from your first image)", + p.Section("Image resolution") + p.Newline() + p.Hintf("The size your images already are, as WxH β€” tracebloc checks every image matches and never resizes. Press Enter to read it from your first image. e.g. 224x224") + p.Newline() + ans, err := pr.Input("Image resolution", "the size your images already are; tracebloc checks it, it never resizes", "", validateOptionalTargetSize) if err != nil { @@ -341,9 +419,11 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b } case push.IsTabular(cat): if a.SchemaFlag == "" { - p.PromptHint("Override the column types the CLI would infer. Blank = infer from the CSV. e.g. age:INT,price:FLOAT,city:VARCHAR") - ans, err := pr.Input("Column schema as col:TYPE,... (blank = infer from the CSV)", - "e.g. age:INT,price:FLOAT", "", validateOptionalSchema) + p.Section("Column types") + p.Newline() + p.Hintf("We infer each column's type from your CSV. Press Enter to accept, or type overrides like age:INT,price:FLOAT.") + p.Newline() + ans, err := pr.Input("Column types", "e.g. age:INT,price:FLOAT", "", validateOptionalSchema) if err != nil { return prompted, err } @@ -351,7 +431,10 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b prompted = true } if push.IsRegressionClass(cat) && a.Spec.LabelPolicy == "" { - p.PromptHint("Regression targets are continuous. 'bucket' groups them into ranges before they leave the cluster; 'passthrough' keeps raw values.") + p.Section("Label policy") + p.Newline() + p.Hintf("Regression targets are continuous. 'bucket' groups them into ranges before they leave the cluster; 'passthrough' keeps raw values.") + p.Newline() ans, err := pr.Select("Label policy", "bucket bins the target before it leaves the cluster", []string{"bucket", "passthrough"}, "bucket") @@ -362,7 +445,10 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b prompted = true } if cat == "time_to_event_prediction" && a.Spec.TimeColumn == "" { - p.PromptHint("The column holding the duration / time-to-event. e.g. time, tenure_days") + p.Section("Time column") + p.Newline() + p.Hintf("The column holding the duration / time-to-event. e.g. time, tenure_days") + p.Newline() ans, err := pr.Input("Time column", "the duration/time column name", "time", nil) if err != nil { return prompted, err diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index 2dfd6810..65b26f42 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -103,11 +103,11 @@ func textDirLayout(t *testing.T) string { func TestRunInteractive_PromptOrder(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "Is this training or test data?": "test", - "What should we call this dataset?": "churn_train", - "Where is your data? (file or folder)": dir, - "Which task?": "Tabular classification", - "Which column holds the class?": "churned", + "Do you want to ingest training or test data?": "test", + "Please name the dataset.": "churn_train", + "Where is your data?": dir, + "Which task?": "tabular_classification", + "Which column holds the label?": "churned", }} a := &runDataIngestArgs{} if err := runInteractive(discardPrinter(), f, a, false /*taskSet*/); err != nil { @@ -117,11 +117,11 @@ func TestRunInteractive_PromptOrder(t *testing.T) { // The four core questions must appear in data-first order, ahead of the // label question. want := []string{ - "Is this training or test data?", - "What should we call this dataset?", - "Where is your data? (file or folder)", + "Do you want to ingest training or test data?", + "Please name the dataset.", + "Where is your data?", "Which task?", - "Which column holds the class?", + "Which column holds the label?", } if !orderedSubsequence(f.asked, want) { t.Errorf("prompt order = %v, want subsequence %v", f.asked, want) @@ -139,11 +139,11 @@ func TestRunInteractive_PromptOrder(t *testing.T) { func TestRunInteractive_PathPromptCopyIsFileOrFolder(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "Is this training or test data?": "train", - "What should we call this dataset?": "churn", - "Where is your data? (file or folder)": dir, - "Which task?": "Tabular classification", - "Which column holds the class?": "churned", + "Do you want to ingest training or test data?": "train", + "Please name the dataset.": "churn", + "Where is your data?": dir, + "Which task?": "tabular_classification", + "Which column holds the label?": "churned", }} a := &runDataIngestArgs{} if err := runInteractive(discardPrinter(), f, a, false /*taskSet*/); err != nil { @@ -151,7 +151,7 @@ func TestRunInteractive_PathPromptCopyIsFileOrFolder(t *testing.T) { } found := false for _, label := range f.asked { - if label == "Where is your data? (file or folder)" { + if label == "Where is your data?" { found = true } if strings.Contains(label, "the folder holding it") { @@ -168,8 +168,8 @@ func TestRunInteractive_PathPromptCopyIsFileOrFolder(t *testing.T) { func TestRunInteractive_SniffEchoesFamily(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Which column holds the class?": "churned", + "Please name the dataset.": "t", + "Which column holds the label?": "churned", }} a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} var buf bytes.Buffer @@ -195,10 +195,10 @@ func TestRunInteractive_SniffEchoesFamily(t *testing.T) { func TestRunInteractive_SniffIsHintNotLock(t *testing.T) { empty := t.TempDir() // no csv, no images/, no texts/ β†’ ambiguous f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "What kind of data is this?": "image", - "Which task?": "Image classification", - "Which column holds the class?": "label", + "Please name the dataset.": "t", + "What kind of data is this?": "image", + "Which task?": "image_classification", + "Which column holds the label?": "label", }} a := &runDataIngestArgs{LocalPath: empty, Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err != nil { @@ -268,8 +268,8 @@ func TestResolveFamily_SurfacesMiscasedHint(t *testing.T) { func TestRunInteractive_ExplicitTaskSkipsSniff(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Which column holds the class?": "churned", + "Please name the dataset.": "t", + "Which column holds the label?": "churned", }} a := &runDataIngestArgs{ LocalPath: dir, @@ -298,7 +298,7 @@ func TestPickTask_FamilyScoped(t *testing.T) { // Text family: all tasks are available now β€” fill-mask (gloss), // classification, the two structured-pair tasks, and the two seq tasks; // image/tabular tasks must not appear. - f := &fakePrompter{answers: map[string]string{"Which task?": "Text classification"}} + f := &fakePrompter{answers: map[string]string{"Which task?": "text_classification"}} var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) id, err := pickTask(p, f, push.FamilyText) @@ -310,14 +310,13 @@ func TestPickTask_FamilyScoped(t *testing.T) { } out := buf.String() for _, want := range []string{ - "Tasks for text data", - "Available now:", - "fill-mask", // MLM gloss (available) - "Text classification", // label - "translation / summarization", // seq2seq gloss (now available) + "What kind of machine learning task is this data for?", + "masked_language_modeling", // MLM gloss (available) + "text_classification", // label + "seq2seq", // seq2seq gloss (now available) "token_classification", // now available "sentence_pair_classification", // now available - "Embeddings", // now available + "embeddings", // now available } { if !strings.Contains(out, want) { t.Errorf("picker output missing %q:\n%s", want, out) @@ -328,7 +327,7 @@ func TestPickTask_FamilyScoped(t *testing.T) { t.Errorf("text picker should have no pending section now:\n%s", out) } // Other families must not leak in. - for _, unwanted := range []string{"Image classification", "Tabular classification", "Survival analysis"} { + for _, unwanted := range []string{"image_classification", "tabular_classification", "time_to_event_prediction"} { if strings.Contains(out, unwanted) { t.Errorf("text picker leaked a non-text task %q:\n%s", unwanted, out) } @@ -339,7 +338,7 @@ func TestPickTask_FamilyScoped(t *testing.T) { // image task is available in the CLI, so the image picker lists them all under // "Available now:" with no greyed "Not yet in the CLI" pending section. func TestPickTask_ImageAllAvailable(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"Which task?": "Image classification"}} + f := &fakePrompter{answers: map[string]string{"Which task?": "image_classification"}} var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) if _, err := pickTask(p, f, push.FamilyImage); err != nil { @@ -347,9 +346,8 @@ func TestPickTask_ImageAllAvailable(t *testing.T) { } out := buf.String() for _, want := range []string{ - "Available now:", - "Image classification", - "Semantic segmentation", // now selectable, no longer pending + "image_classification", + "semantic_segmentation", // now selectable, no longer pending } { if !strings.Contains(out, want) { t.Errorf("image picker missing %q:\n%s", want, out) @@ -366,7 +364,7 @@ func TestPickTask_ImageAllAvailable(t *testing.T) { // TestPickTask_TabularGloss: the tabular picker shows the survival-analysis // gloss for time_to_event_prediction and can select it back to its id. func TestPickTask_TabularGloss(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"Which task?": "Survival analysis"}} + f := &fakePrompter{answers: map[string]string{"Which task?": "time_to_event_prediction"}} var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) id, err := pickTask(p, f, push.FamilyTabular) @@ -376,7 +374,7 @@ func TestPickTask_TabularGloss(t *testing.T) { if id != "time_to_event_prediction" { t.Errorf("id = %q, want time_to_event_prediction", id) } - if !strings.Contains(buf.String(), "Survival analysis") { + if !strings.Contains(buf.String(), "time_to_event_prediction") { t.Errorf("tabular picker missing the survival-analysis gloss:\n%s", buf.String()) } } @@ -388,9 +386,9 @@ func TestRunInteractive_LabelSelectFromHeaders(t *testing.T) { dir := tabularDir(t) // header: age,income,churned // Script an answer that only works if the options were the real headers. f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Which task?": "Tabular classification", - "Which column holds the class?": "income", + "Please name the dataset.": "t", + "Which task?": "tabular_classification", + "Which column holds the label?": "income", }} a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err != nil { @@ -418,7 +416,7 @@ func TestRunInteractive_RegressionLabelWording(t *testing.T) { if !contains(f.asked, "Which column holds the value to predict?") { t.Errorf("regression should ask for the value to predict; asked=%v", f.asked) } - if contains(f.asked, "Which column holds the class?") { + if contains(f.asked, "Which column holds the label?") { t.Errorf("regression must not use the class wording") } if a.Spec.LabelColumn != "income" { @@ -432,7 +430,7 @@ func TestRunInteractive_RegressionLabelWording(t *testing.T) { func TestRunInteractive_LabelFreeTextFallback(t *testing.T) { empty := t.TempDir() // no labels.csv β†’ PreviewLabelHeaders errors f := &fakePrompter{answers: map[string]string{ - "Which column holds the class?": "my_label", + "Which column holds the label?": "my_label", }} a := &runDataIngestArgs{ LocalPath: empty, @@ -451,8 +449,8 @@ func TestRunInteractive_LabelFreeTextFallback(t *testing.T) { func TestRunInteractive_MLMSkipsLabel(t *testing.T) { dir := textDirLayout(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "mlm_train", - "Which task?": "fill-mask", + "Please name the dataset.": "mlm_train", + "Which task?": "masked_language_modeling", }} a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err != nil { @@ -492,8 +490,8 @@ func TestRunInteractive_SkipsProvidedValues(t *testing.T) { func TestRunInteractive_Keypoint(t *testing.T) { dir := imageDirLayout(t) f := &fakePrompter{answers: map[string]string{ - "Number of keypoints per sample": "17", - "Which column holds the class?": "image_label", + "How many keypoints per sample?": "17", + "Which column holds the label?": "image_label", }} a := &runDataIngestArgs{ LocalPath: dir, @@ -540,8 +538,8 @@ func TestRunInteractive_Cancel(t *testing.T) { no := false f := &fakePrompter{ answers: map[string]string{ - "What should we call this dataset?": "t", - "Which column holds the class?": "churned", + "Please name the dataset.": "t", + "Which column holds the label?": "churned", }, confirm: &no, } @@ -554,7 +552,7 @@ func TestRunInteractive_Cancel(t *testing.T) { // TestRunInteractive_RejectsBadName: the name prompt runs // push.ValidateTableName, so an unsafe name surfaces as an error. func TestRunInteractive_RejectsBadName(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"What should we call this dataset?": "../bad"}} + f := &fakePrompter{answers: map[string]string{"Please name the dataset.": "../bad"}} a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, true); err == nil { t.Fatal("expected an error for an invalid name, got nil") @@ -566,8 +564,8 @@ func TestRunInteractive_RejectsBadName(t *testing.T) { // directory (empty path β†’ Abs("") β†’ cwd). func TestRunInteractive_RejectsEmptyPath(t *testing.T) { f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Where is your data? (file or folder)": " ", + "Please name the dataset.": "t", + "Where is your data?": " ", }} a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err == nil { @@ -583,9 +581,9 @@ func TestRunInteractive_RejectsEmptyPath(t *testing.T) { func TestRunInteractive_TrimsPath(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Where is your data? (file or folder)": " " + dir + " ", - "Which column holds the class?": "churned", + "Please name the dataset.": "t", + "Where is your data?": " " + dir + " ", + "Which column holds the label?": "churned", }} a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err != nil { @@ -601,21 +599,23 @@ func TestRunInteractive_TrimsPath(t *testing.T) { } } -// TestRunInteractive_ShowsExampleHints: the name and path prompts carry a -// visible example, so the guided flow teaches as it goes. +// TestRunInteractive_ShowsExampleHints: the path and schema steps carry a +// visible example, so the guided flow teaches as it goes. (LocalPath is left +// empty so the path step β€” and its per-modality examples β€” actually renders.) func TestRunInteractive_ShowsExampleHints(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "churn_train", - "Which column holds the class?": "churned", + "Please name the dataset.": "churn_train", + "Where is your data?": dir, + "Which column holds the label?": "churned", }} - a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) if err := runInteractive(p, f, a, false); err != nil { t.Fatalf("runInteractive: %v", err) } - for _, want := range []string{"e.g. churn_train", "age:INT"} { + for _, want := range []string{"~/data/patients.csv", "age:INT"} { if !strings.Contains(buf.String(), want) { t.Errorf("interactive output missing hint %q:\n%s", want, buf.String()) } diff --git a/internal/cli/testdata/golden/00-home.golden b/internal/cli/testdata/golden/00-home.golden new file mode 100644 index 00000000..e2166ef9 --- /dev/null +++ b/internal/cli/testdata/golden/00-home.golden @@ -0,0 +1,257 @@ +tb / tracebloc β€” home +===================== +What you see when you run `tb` (or `tracebloc`) with no arguments. Covers every +state the home view resolves to. + +$ tb # signed in Β· secure environment Online + + + Welcome to your secure environment for AI, Lukas πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + βœ“ Secure environment "hello-world" Β· Online (12 CPU Β· 23 GiB) + + + Your data + + Β· tb data ingest load a dataset into your secure environment + Β· tb data list list your datasets + Β· tb data delete remove a dataset + + + Your secure environment + + Β· tb resources manage compute & memory + Β· tb doctor check the connection & diagnose issues + Β· tb delete remove tracebloc from this machine + + + Add --help to any command for the flags. + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # signed in Β· running, couldn't confirm connection + + + Welcome to your secure environment for AI, Lukas πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + ⚠ Secure environment "hello-world" Β· running β€” couldn't confirm it's connected to tracebloc β€” run tb doctor + + + Your data + + Β· tb data ingest load a dataset into your secure environment + Β· tb data list list your datasets + Β· tb data delete remove a dataset + + + Your secure environment + + Β· tb resources manage compute & memory + Β· tb doctor check the connection & diagnose issues + Β· tb delete remove tracebloc from this machine + + + Add --help to any command for the flags. + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # signed in Β· running, backend reports not online + + + Welcome to your secure environment for AI, Lukas πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + ⚠ Secure environment "hello-world" Β· running, but tracebloc hasn't heard from it β€” run tb doctor + + + Your data + + Β· tb data ingest load a dataset into your secure environment + Β· tb data list list your datasets + Β· tb data delete remove a dataset + + + Your secure environment + + Β· tb resources manage compute & memory + Β· tb doctor check the connection & diagnose issues + Β· tb delete remove tracebloc from this machine + + + Add --help to any command for the flags. + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # signed in Β· starting up + + + Welcome to your secure environment for AI, Lukas πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + ⚠ Secure environment "hello-world" Β· starting up, not ready yet β€” run tb doctor + + + Your data + + Β· tb data ingest load a dataset into your secure environment + Β· tb data list list your datasets + Β· tb data delete remove a dataset + + + Your secure environment + + Β· tb resources manage compute & memory + Β· tb doctor check the connection & diagnose issues + Β· tb delete remove tracebloc from this machine + + + Add --help to any command for the flags. + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # signed in Β· offline (can't reach it) + + + Welcome to your secure environment for AI, Lukas πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + βœ— Secure environment "hello-world" Β· can't reach it from here β€” run tb doctor + + + Your data + + Β· tb data ingest load a dataset into your secure environment + Β· tb data list list your datasets + Β· tb data delete remove a dataset + + + Your secure environment + + Β· tb resources manage compute & memory + Β· tb doctor check the connection & diagnose issues + Β· tb delete remove tracebloc from this machine + + + Add --help to any command for the flags. + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # signed in Β· no secure environment on this machine + + + Welcome to tracebloc β€” your secure environment for AI πŸ‘‹ + + ────────────────────────────── + + βœ“ Signed in as lukas@tracebloc.io + ⚠ No secure environment on this machine yet β€” run the installer to set one up. + + + Your secure environment + + Β· tb doctor check the connection & diagnose issues + + + + ────────────────────────────── + + love from tracebloc πŸ’š + + +$ tb # not signed in + + + Welcome to tracebloc β€” your secure environment for AI πŸ‘‹ + + ────────────────────────────── + + βœ— Not signed in yet. + + + Start here + + Β· tb login sign in to tracebloc + + + + ────────────────────────────── + + love from tracebloc πŸ’š + + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc --help +The tracebloc CLI connects machines to tracebloc as clients and +manages the datasets that models train on. Your data stays on your +infrastructure β€” models from other collaborators come to it, once you +approve them. + +Two kinds of commands: + + Your account (sign in first): login, logout, auth, client + This machine's client: data, cluster + +A typical first session: + + tracebloc login # sign in or create your account (browser) + tracebloc data ingest ./my-data # stage a dataset into your client + tracebloc data list # see what's in the cluster + +The CLI finds your cluster through your kubeconfig, stages data onto +the cluster's shared storage, and reports progress as it goes. No +Helm, no YAML, no kubectl needed. + +Usage: + tracebloc [flags] + tracebloc [command] + +Available Commands: + auth Inspect tracebloc authentication state + client Provision this machine's tracebloc client + cluster Inspect the cluster the CLI is currently targeting + completion Generate the autocompletion script for the specified shell + data Manage the datasets in your secure environment + delete Offboard this machine from tracebloc (revoke, uninstall, reclaim disk) + doctor Check your secure environment is connected and ready to run training + help Help about any command + login Sign in to tracebloc in your browser (device flow) + logout Sign out (revoke the token server-side and clear it locally) + resources Show how much of this machine tracebloc may use + version Print the tracebloc CLI version, git SHA, and build date + +Flags: + -h, --help help for tracebloc + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +Use "tracebloc [command] --help" for more information about a command. diff --git a/internal/cli/testdata/golden/01-data-ingest.golden b/internal/cli/testdata/golden/01-data-ingest.golden new file mode 100644 index 00000000..678512a0 --- /dev/null +++ b/internal/cli/testdata/golden/01-data-ingest.golden @@ -0,0 +1,332 @@ +tb data ingest β€” stage a dataset into your secure environment +============================================================= +What you see when you run `tb data ingest` with no flags: a short intro, a +four-step guided setup (intent, name, path, task) then the task-specific +questions, and β€” after you confirm β€” the run itself. The setup is +driven through the real flow for one task in each family (tabular, image, text) +so the task-specific questions are visible; each core question prints as a +`Step N of 4 Β· …` header, the task-specific ones (the label column, and extras +like resolution or schema) as their own header, the +supporting line beneath it, and the `?` line shows your answer. The run (shown +once, for tabular) is the three steps + the final summary as the CLI renders +them. Passing flags (--as, --task, a path, …) skips the matching questions. The +other tasks' extra questions (keypoints, label policy, time column), +self-supervised text (which skips the label question), and the failure-summary +wordings are in zz-all-strings.golden. The raw ingestor stream the CLI streams +through (MySQL waits, the πŸ“Š banner, per-validator lines) is the engine's own +stdout β€” not CLI copy β€” so it isn't shown. (`tb ingest` is a hidden deprecated +alias; `push` is a deprecated alias of the verb.) + +$ tb data ingest # guided Β· tabular classification + + Ingest datasets to your secure environment. + For help: https://docs.tracebloc.io/create-use-case/prepare-dataset + + Step 1 of 4 Β· Do you want to ingest training or test data? + +? train + + Step 2 of 4 Β· Please name the dataset. + +? hospital_train + + Step 3 of 4 Β· Where is your data? + + Give the path to a file or a folder β€” whichever holds your data: + Β· Tabular one CSV file e.g. ~/data/patients.csv + Β· Images a folder with labels.csv + images/ e.g. ~/data/xray/ + Β· Text a folder with labels.csv + texts/ e.g. ~/data/reviews/ + +? ~/data/patients + βœ” Found a CSV table β€” this is tabular data. + + Step 4 of 4 Β· What kind of machine learning task is this data for? + + tabular_classification predict a class from table columns + tabular_regression predict a number from table columns + time_series_forecasting predict future values from past ones + time_series_classification predict a class for each whole sequence + time_to_event_prediction predict how long until an event happens + +? tabular_classification + + Which column holds the label? + + The answer the model learns to produce β€” for classification, the class. e.g. diagnosis, churned + +? churned + + Column types + + We infer each column's type from your CSV. Press Enter to accept, or type overrides like age:INT,price:FLOAT. + +? + + Review + name: hospital_train + task: tabular_classification + intent: train + path: ~/data/patients + label column: churned + schema: infer from CSV + +? Proceed with the ingest? Yes + +$ tb data ingest # guided Β· image classification + + Ingest datasets to your secure environment. + For help: https://docs.tracebloc.io/create-use-case/prepare-dataset + + Step 1 of 4 Β· Do you want to ingest training or test data? + +? train + + Step 2 of 4 Β· Please name the dataset. + +? xray_train + + Step 3 of 4 Β· Where is your data? + + Give the path to a file or a folder β€” whichever holds your data: + Β· Tabular one CSV file e.g. ~/data/patients.csv + Β· Images a folder with labels.csv + images/ e.g. ~/data/xray/ + Β· Text a folder with labels.csv + texts/ e.g. ~/data/reviews/ + +? ~/data/xray + βœ” Found labels.csv and an images/ folder β€” this is image data. + + Step 4 of 4 Β· What kind of machine learning task is this data for? + + image_classification sort images into classes + object_detection draw boxes around objects in an image + keypoint_detection locate landmark points on an image (e.g. pose) + semantic_segmentation label every pixel in an image + +? image_classification + + Which column holds the label? + + The answer the model learns to produce β€” for classification, the class. e.g. diagnosis, churned + +? label + + Image resolution + + The size your images already are, as WxH β€” tracebloc checks every image matches and never resizes. Press Enter to read it from your first image. e.g. 224x224 + +? 224x224 + + Review + name: xray_train + task: image_classification + intent: train + path: ~/data/xray + label column: label + resolution: 224x224 + +? Proceed with the ingest? Yes + +$ tb data ingest # guided Β· text classification + + Ingest datasets to your secure environment. + For help: https://docs.tracebloc.io/create-use-case/prepare-dataset + + Step 1 of 4 Β· Do you want to ingest training or test data? + +? train + + Step 2 of 4 Β· Please name the dataset. + +? reviews_train + + Step 3 of 4 Β· Where is your data? + + Give the path to a file or a folder β€” whichever holds your data: + Β· Tabular one CSV file e.g. ~/data/patients.csv + Β· Images a folder with labels.csv + images/ e.g. ~/data/xray/ + Β· Text a folder with labels.csv + texts/ e.g. ~/data/reviews/ + +? ~/data/reviews + βœ” Found labels.csv and a texts/ folder β€” this looks like text data. + + Step 4 of 4 Β· What kind of machine learning task is this data for? + + text_classification sort text snippets into classes + masked_language_modeling predict masked-out words β€” no labels needed + causal_language_modeling predict the next word in a sequence + seq2seq map an input sequence to an output one + token_classification label each word in a sequence + sentence_pair_classification label how two texts relate + embeddings learn vector representations from text pairs + +? text_classification + + Which column holds the label? + + The answer the model learns to produce β€” for classification, the class. e.g. diagnosis, churned + +? label + + Review + name: reviews_train + task: text_classification + intent: train + path: ~/data/reviews + label column: label + +? Proceed with the ingest? Yes + +$ tb data ingest # after you confirm β€” the run (tabular) + +Step 1/3 Check your data + Reading your files locally first β€” nothing has touched your secure environment yet β€” so a layout or settings problem shows up right away. + + Local dataset + root: ~/data/patients + data CSV: ~/data/patients/data.csv + columns: 7 + total size: 51.57 KiB + +Step 2/3 Copy into your secure environment + Your files are copied securely into your secure environment's storage β€” set up and cleaned up for you. + +Step 3/3 Validate and load + Submitting the run, then following along as tracebloc validates your data and loads it into the table β€” progress streams below. + This follows the run for up to an hour; a longer run keeps going on its own (or start it with --detach and check back later). + βœ” Ingestion complete β€” ingested 849 of 849 records (100.0%) + + Ingestion summary + ingestor ID: 80c224bd-202b-4a6f-9362-61c84599f334 + total records: 849 + inserted: 849 + + What's next + Β· Your data is registered as a dataset. View it at https://ai.tracebloc.io/metadata + Β· To train or benchmark models on it, create a use case at https://ai.tracebloc.io/my-use-cases + Pick this dataset when you set it up. + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc data ingest --help +Ingests a local dataset into your secure environment's storage, +submits the ingestion run, and follows it to completion (streaming +progress + the final summary). Your data never leaves your own +infrastructure. Supports 16 tasks across the image, text, and +tabular / time-series families; pick one with --task. + + is the data itself. What it looks like depends on the task: + + tabular / time-series β€” the dataset is a single CSV. Pass the .csv + file directly, or a folder holding exactly one .csv: + + churn.csv (the .csv file itself) + or + churn/ + data.csv (the one .csv in the folder) + + image (classification, object/keypoint detection) β€” a folder with + labels.csv + an images/ subfolder: + + cats_dogs/ + labels.csv (required) + images/ (required) + 001.jpg + ... + + text (classification, masked language modeling) β€” a folder with + labels.csv + a texts/ subfolder (masked language modeling uses sequences/): + + reviews/ + labels.csv (required) + texts/ (required β€” sequences/ for masked language modeling) + 001.txt + ... + +A bare .csv file is accepted only for the tabular / time-series family; +image and text datasets must be a folder. + +Accepted image extensions: .jpg, .jpeg, or .png (case-insensitive). +All images in one dataset must share a single type β€” the cluster +validates the type it was told to expect. + +v0.1 caps the dataset at 1 GiB total + 500 MiB per file. Larger +datasets need the v0.2 cloud-source story (S3/GCS/HTTPS sources) β€” +see tracebloc/client#147 non-goals. + +Exit codes: + 0 files staged + ingested successfully (or --detach: just staged + submitted) + 2 schema validation failed (synthesized spec rejected) or + v0.1-unsupported task passed + 3 local-layout or kubeconfig error + 4 cluster reachable but no tracebloc client / shared storage missing + 5 ingestor SA token couldn't be obtained, or jobs-manager + rejected the token (401/403) + 6 destination table already exists (re-run with --overwrite to + replace it, or pick a different --name) + 7 pre-flight succeeded but staging the files failed + (Pod creation, image pull, exec stream, or remote tar error) β€” + or, with --overwrite, removing the old table failed + 8 jobs-manager rejected the submit (4xx/5xx other than auth) + 9 ingestion Job exited non-zero, or completed with row-level + failures the summary panel reports + +Usage: + tracebloc data ingest [flags] + +Aliases: + ingest, push + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + --detach kubectl logs -f -n job/ exit immediately after jobs-manager accepts the run (no log streaming, no summary panel). Use for CI scenarios; reconnect later with kubectl logs -f -n job/. + --dry-run validate + discover + walk, but don't create any cluster resources + -h, --help help for ingest + --idempotency-key string reuse this idempotency key across retry attempts (default: fresh per invocation). jobs-manager treats a duplicate key as a replay and attaches to the existing Job rather than spawning a new one β€” useful for at-most-once-across-attempts semantics. + --image-digest images.ingestor.digest pin the ingestor container image to a specific digest (default: jobs-manager picks the cluster-configured images.ingestor.digest). Format: sha256:. + --intent string is this training or test data? train|test (default train) + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + --label-column string name of the label/target column (in labels.csv for image tasks, in the data CSV for tabular) + --label-policy string regression-class only (tabular_regression, time_series_forecasting, time_to_event_prediction): passthrough|bucket (default bucket β€” bins the target so the raw value never leaves the cluster) + --min-size string image tasks only: reject images smaller than WxH before the ingest (e.g. 64x64). Set it to the smallest size your model can train on β€” raise or lower it freely. Default: unset (no local size check). + --name string a name for this dataset β€” start with a letter or underscore, then letters/digits/underscores β€” you'll reference it by this name when you start a training run + -n, --namespace string namespace where your tracebloc client is installed + --no-input disable interactive prompts; fail on missing required values (for CI/scripts) + --number-of-keypoints int keypoint_detection only: number of keypoints per sample (required; e.g. 17 for COCO pose) + --output-json emit a machine-readable JSON result on stdout (human output β†’ stderr; implies --no-input) + --overwrite tracebloc data delete replace the destination table if it already exists: its current table + files are removed first (same as tracebloc data delete), then the new data is ingested. Not combinable with --idempotency-key + --schema string tabular/time-series only: column types as col:TYPE,col:TYPE (e.g. age:INT,price:FLOAT). Default: inferred from the CSV (INT/BIGINT/FLOAT/BOOLEAN/DATE/DATETIME/VARCHAR(n)). + --stage-pod-image string override the ephemeral stage Pod's image (default: digest-pinned alpine 3.20 baked into the CLI). Pin by digest in your override too β€” tag-only refs drift silently. + --target-size string image tasks only: the resolution your images already are, as WxH (e.g. 512x512). tracebloc never resizes β€” it checks every image is exactly this size and rejects any that differ. Default: read from your first image. + --task string the task this data is for, one of: image_classification, object_detection, keypoint_detection, text_classification, masked_language_modeling, tabular_classification, tabular_regression, time_series_forecasting, time_series_classification, time_to_event_prediction, causal_language_modeling, seq2seq, token_classification, sentence_pair_classification, embeddings, semantic_segmentation. Omit it on a terminal to pick interactively. + --time-column string time_to_event_prediction only: name of the time/duration column (default: a column named "time") + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc data validate --help +Reads , parses it as YAML, and validates it against the bundled +ingest.v1.json schema (synced from tracebloc/data-ingestors). Prints +violations in the same JSON-pointer-prefixed format the cluster's +jobs-manager uses, and exits non-zero if any are found. + +Useful as a pre-flight before running `tracebloc data ingest` β€” +millisecond local feedback instead of a multi-second cluster round +trip. + +Exit codes: + 0 YAML parses and validates cleanly + 2 YAML parses but has schema violations (printed to stderr) + 3 YAML doesn't parse or file isn't readable + +Usage: + tracebloc data validate [flags] + +Flags: + -h, --help help for validate + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/02-data-list.golden b/internal/cli/testdata/golden/02-data-list.golden new file mode 100644 index 00000000..17931f48 --- /dev/null +++ b/internal/cli/testdata/golden/02-data-list.golden @@ -0,0 +1,64 @@ +tb data list β€” list your datasets +================================= +What you see when you run `tb data list`. Covers empty, populated, and --all. + +$ tb data list # no datasets yet + + Datasets in hello-world (0) + + No datasets yet β€” ingest one with `tracebloc data ingest`. + +$ tb data list # with datasets (system tables hidden) + + Datasets in hello-world β€” 2 Β· 1.25 GiB + 1 system table(s) hidden β€” show with --all. + + Image classification Β· 2 + βœ” xray_test test 3000 images 256.00 MiB jpg Β· 2 classes β€” + βœ” xray_train train 12000 images 1.00 GiB jpg Β· 2 classes β€” + +$ tb data list --all # including system tables + + Datasets in hello-world β€” 2 Β· 1.25 GiB + + Image classification Β· 2 + βœ” xray_test test 3000 images 256.00 MiB jpg Β· 2 classes β€” + βœ” xray_train train 12000 images 1.00 GiB jpg Β· 2 classes β€” + + System Β· 1 + Β· ingest_run_journal β€” + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc data list --help +Lists the datasets ingested into your client β€” the tables in training_test_datasets +on the cluster β€” grouped by modality, with each dataset's split (train/test), +record count, size, format, and when it was ingested. + +With no flags it uses your current kubeconfig context and its namespace; +the flags below override that, same as `cluster info` and `data ingest`. +Framework tables (the ingest-run journal) are hidden unless you pass --all. +For the full catalog, see the dashboard at https://ai.tracebloc.io/metadata. + +Exit codes: + 0 listed successfully (including an empty list) + 3 kubeconfig error + 4 cluster reachable but no tracebloc client in the namespace + 7 couldn't query the cluster for datasets + +Usage: + tracebloc data list [flags] + +Flags: + --all include framework/system tables (e.g. the ingest-run journal), normally hidden + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + -h, --help help for list + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your tracebloc client is installed + --output-json emit the dataset list as JSON on stdout (human output β†’ stderr) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/03-data-delete.golden b/internal/cli/testdata/golden/03-data-delete.golden new file mode 100644 index 00000000..a47f8b1e --- /dev/null +++ b/internal/cli/testdata/golden/03-data-delete.golden @@ -0,0 +1,50 @@ +tb data delete β€” delete a dataset +================================= +What you see when you run `tb data delete `. The command confirms before +it deletes; the confirmation prompt, the progress, and the success/failure lines +stream during the flow (not a stable screen) β€” they're all in zz-all-strings.golden. + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc data delete --help +Removes the in-cluster artifacts a previous `data ingest` created +for a table: the MySQL table in training_test_datasets and the dataset's +directories on the shared PVC. Destructive and not undoable. + +The dataset's catalog metadata on the tracebloc backend is never removed β€” it +is kept as a record on tracebloc, marked unavailable, so a collaborator's run +that referenced it still has its history. + +Exit codes: + 0 artifacts removed (or --dry-run, or the user declined) + 2 invalid table name + 3 kubeconfig error, or refused (no confirmation off a terminal) + 4 cluster reachable but no tracebloc client / shared storage missing, + or the client's dataset list couldn't be read (can't confirm the target) + 5 no dataset by that name on this client (nothing to delete) + 7 teardown failed mid-flight (table drop or PVC rm errored) + +With --output-json, stdout carries exactly one JSON result object per run +(human output goes to stderr) and the exit codes above are unchanged; see +docs/json-output.md for the shape and the stability promise. + +Usage: + tracebloc data delete [flags] + +Aliases: + delete, rm + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + --dry-run show what would be deleted without deleting anything + -h, --help help for delete + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your tracebloc client is installed + --output-json emit the delete result as JSON on stdout (human output β†’ stderr; never prompts β€” pass --yes to delete, or --dry-run) + -y, --yes skip the confirmation prompt (required when not on a terminal) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/04-resources.golden b/internal/cli/testdata/golden/04-resources.golden new file mode 100644 index 00000000..7c804d22 --- /dev/null +++ b/internal/cli/testdata/golden/04-resources.golden @@ -0,0 +1,90 @@ +tb resources β€” see / change what a training run may use +======================================================= +What you see when you run `tb resources`. The view reads live cluster capacity, +so it isn't a stable screen: it prints "Your secure environment is equipped +with: …", "A training run is allocated up to: …", and a hint to run +`tb resources set` β€” all indexed in zz-all-strings.golden. `tb resources set` is +a guided walkthrough (prompts stream during the flow; also in the backstop). + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc resources --help +Shows, in plain terms, how much of this machine tracebloc may use: + + β€’ Your secure environment β€” the CPU and memory it can schedule + β€’ Each training run β€” the per-run ceiling every run may use (cluster-wide) + +No Kubernetes concepts, no YAML β€” one number for your environment and one for +each training run's share of it. + +Raise the share with `tracebloc resources set`. Run with --verbose for the +per-node breakdown and the raw values. + +Exit codes: + 0 shown + 3 kubeconfig could not be loaded / cluster unreachable + 4 cluster reachable but no tracebloc client found here + +Usage: + tracebloc resources [flags] + tracebloc resources [command] + +Available Commands: + set Raise how much of this machine tracebloc may use + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + -h, --help help for resources + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your tracebloc client is installed (default: the context's namespace, or 'default') + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +Use "tracebloc resources [command] --help" for more information about a command. + +$ tracebloc resources set --help +Raise the per-training-run ceiling β€” how much of this machine a single +training run may use. + +Run it on a terminal with no flags for a guided walkthrough: + + tracebloc resources set + +Or set it directly (for scripts / non-interactive shells): + + tracebloc resources set --cores 4 --memory 16Gi an explicit per-run ceiling + tracebloc resources set --cores 4 change CPU only, keep the rest + tracebloc resources set max let a run use the whole machine + +The number you set is what ONE training run may use. tracebloc keeps a small fixed +amount (about 1 core and 3 GiB) for itself on top β€” you never have to subtract it. +The new ceiling applies to your NEXT training run; a run already going keeps its +size. + +Exit codes: + 0 applied (or nothing to change) + 2 the requested size doesn't fit this machine / bad input + 3 kubeconfig could not be loaded / cluster unreachable + 4 cluster reachable but no tracebloc client found here + +Usage: + tracebloc resources set [max] [flags] + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + --cores string CPU cores one training run may use (e.g. 4) + --dry-run show exactly what would change and apply nothing + --gpus int whole GPUs one training run may use (only on a GPU machine) + -h, --help help for set + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + --memory string memory one training run may use (e.g. 16 or 16Gi β€” the number is GiB) + -n, --namespace string namespace where your tracebloc client is installed (default: the context's namespace, or 'default') + --yes skip the confirmation prompt (for automation) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/05-doctor.golden b/internal/cli/testdata/golden/05-doctor.golden new file mode 100644 index 00000000..51d6d648 --- /dev/null +++ b/internal/cli/testdata/golden/05-doctor.golden @@ -0,0 +1,85 @@ +tb doctor β€” is my secure environment healthy? +============================================= +What you see when you run `tb doctor`. The two rollup lines (Connected, Ready) +plus a verdict are shown below for the healthy and the can't-fully-check cases. +The failure variants (Not connected β€” …, Not ready β€” …) and their remedies vary +with the reachability classification and embed the launcher name, so the full set +is indexed in zz-all-strings.golden. --verbose adds a Kubernetes breakdown +(context/server/namespace + each granular check); those strings are in the +backstop too. + +$ tb doctor # healthy + Signed in as lukas@tracebloc.io + Secure environment "hello-world" + + βœ” Connected to tracebloc + βœ” Ready to run training + + βœ” Everything looks good β€” you're ready to run training. + +$ tb doctor # connected, but a check couldn't complete (e.g. RBAC) + Signed in as lukas@tracebloc.io + Secure environment "hello-world" + + βœ” Connected to tracebloc + Β· Ready to run training β€” couldn't check your workloads (run with --verbose) + + Β· No problems found, but some checks couldn't finish β€” re-run with --verbose for detail. + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc doctor --help +Checks, in plain terms, whether your secure environment is connected to +tracebloc and ready to run training β€” and if something's wrong, exactly what to +do about it. + + --verbose the full technical breakdown (for support) + --diagnose write a redacted support bundle to email to tracebloc + +Exit codes: + 0 healthy + 2 a problem was found + 3 couldn't read your local config + +Usage: + tracebloc doctor [flags] + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + --diagnose write a redacted support bundle for tracebloc support and exit + -h, --help help for doctor + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your secure environment is installed (default: your active client's) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc cluster doctor --help +Checks, in plain terms, whether your secure environment is connected to +tracebloc and ready to run training β€” and if something's wrong, exactly what to +do about it. + + --verbose the full technical breakdown (for support) + --diagnose write a redacted support bundle to email to tracebloc + +Exit codes: + 0 healthy + 2 a problem was found + 3 couldn't read your local config + +Usage: + tracebloc cluster doctor [flags] + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + --diagnose write a redacted support bundle for tracebloc support and exit + -h, --help help for doctor + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your secure environment is installed (default: your active client's) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/06-delete.golden b/internal/cli/testdata/golden/06-delete.golden new file mode 100644 index 00000000..ed65049b --- /dev/null +++ b/internal/cli/testdata/golden/06-delete.golden @@ -0,0 +1,69 @@ +tb delete β€” remove tracebloc from this machine +============================================== +What you see when you run `tb delete`. The pre-flight summary (below) is shown +before you confirm, for both keep-data and remove-data. The confirmation prompt +and the teardown progress stream during the flow β€” those strings are in +zz-all-strings.golden. + +$ tb delete # summary Β· keep my data + + This will remove + Β· This machine's credential β€” so tracebloc can no longer reach it + Β· Your secure environment "lukas-macbook" and everything it runs on this machine + Β· tracebloc's downloaded images + Β· The tracebloc CLI (your local data & config are kept β€” --keep-data) + + Kept on tracebloc + Β· Your use cases and the models trained here + Β· Your dataset records (marked unavailable, not deleted) + + Left alone + Β· Docker and related tools β€” remove them yourself if you no longer need them + +$ tb delete --remove-data # summary Β· remove my data too + + This will remove + Β· This machine's credential β€” so tracebloc can no longer reach it + Β· Your secure environment "lukas-macbook" and everything it runs on this machine + Β· tracebloc's downloaded images + Β· Your local data & config (~/.tracebloc) and the tracebloc CLI β€” can't be undone + + Kept on tracebloc + Β· Your use cases and the models trained here + Β· Your dataset records (marked unavailable, not deleted) + + Left alone + Β· Docker and related tools β€” remove them yourself if you no longer need them + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc delete --help +Removes tracebloc from this machine: revokes the machine credential, +uninstalls the Helm release, deletes the local cluster, reclaims the tracebloc +container images, and clears local state β€” then removes the CLI itself. + +Your use cases, datasets' catalog entries, and the models trained here are KEPT +on tracebloc as a record (a colleague's model must not vanish because you +reclaimed this box). System software the installer laid down β€” Docker, kubectl, +k3d, helm, NVIDIA drivers β€” is left in place; remove it yourself if unused. + +Destructive: on a single-host install the on-prem datasets live on this machine +and are erased. Not undoable. + +Usage: + tracebloc delete [flags] + +Flags: + --context string kubeconfig context for the target cluster (default: current-context) + --force offboard even if tracebloc still reports this client online + -h, --help help for delete + --keep-data uninstall the software but keep ~/.tracebloc (local config + on-host datasets) + --kubeconfig string path to the kubeconfig for the target cluster (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace of this machine's tracebloc release (default: the active client's namespace) + --yes skip the typed-name confirmation (for automation) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/07-login.golden b/internal/cli/testdata/golden/07-login.golden new file mode 100644 index 00000000..d08e9359 --- /dev/null +++ b/internal/cli/testdata/golden/07-login.golden @@ -0,0 +1,58 @@ +tb login / logout β€” sign in and out +=================================== +What you see when you run `tb login`. Sign-in is a device flow: the CLI prints an +"Open " line and an "Enter " line, waits, then confirms β€” that copy +streams during the flow (not a stable screen), so it's in zz-all-strings.golden. +`tb auth status` reports who you're signed in as. + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc login --help +Sign in to tracebloc. The CLI prints a URL + short code; open the URL +on any device (your laptop or phone), sign in the way you already do +(password, Google, or GitHub), and approve the code. The CLI stores a +user token in ~/.tracebloc (mode 0600). + +Works on a headless / SSH box β€” the browser and the CLI need not share a +machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks. + +Usage: + tracebloc login [flags] + +Flags: + --env string backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod) + -h, --help help for login + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc logout --help +Sign out (revoke the token server-side and clear it locally) + +Usage: + tracebloc logout [flags] + +Flags: + -h, --help help for logout + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc auth status --help +Show whether you're signed in, and to which backend + +Usage: + tracebloc auth status [flags] + +Flags: + --check exit 0 only if signed in with a backend-valid token, else 1; silent unless --verbose + --env string backend environment the check targets: dev|stg|prod (default: $CLIENT_ENV, then prod) + -h, --help help for status + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/08-client.golden b/internal/cli/testdata/golden/08-client.golden new file mode 100644 index 00000000..6ac292bf --- /dev/null +++ b/internal/cli/testdata/golden/08-client.golden @@ -0,0 +1,91 @@ +tb client β€” register / list / inspect environments +================================================== +What you see under `tb client`. `tb client create` shows a review (below) before +it registers a new secure environment. `tb client list` / `tb client status` read +live backend state, so they aren't stable screens β€” their strings are in +zz-all-strings.golden. + +$ tb client create # review, before you confirm + + Review + name: lukas-macbook + namespace: lukas-macbook + location: DE + cluster: a1b2c3d4 (anchors this client β€” re-runs adopt it) + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc client --help +Provision a tracebloc client for this machine. Requires sign-in first +(`tracebloc login`). To remove tracebloc from this machine, use +`tracebloc delete`. + +Usage: + tracebloc client [flags] + tracebloc client [command] + +Available Commands: + status Show whether tracebloc can see this machine's client (online) + +Flags: + -h, --help help for client + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +Use "tracebloc client [command] --help" for more information about a command. + +$ tracebloc client create --help +Provision a tracebloc client for this machine (auto-named; no flags required) + +Usage: + tracebloc client create [flags] + +Flags: + --context string kubeconfig context for the target cluster (default: current-context) + --credential-file string write the machine credential to this path (mode 0600, sourceable env) instead of printing it β€” for the installer to feed the chart (never shown on the terminal) + -h, --help help for create + --kubeconfig string path to the kubeconfig for the target cluster (default: $KUBECONFIG, then ~/.kube/config) β€” read to anchor the client to this cluster + --location string optional location zone for carbon reporting, e.g. DE (default: $TRACEBLOC_CLIENT_LOCATION; omitted if unset) + --name string client name (default: $TRACEBLOC_CLIENT_NAME, else auto-generated -NN; shown on your dashboard + carbon reports) + --yes skip the confirmation prompt + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc client list --help +List the clients in your account + +Usage: + tracebloc client list [flags] + +Aliases: + list, ls + +Flags: + -h, --help help for list + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +$ tracebloc client status --help +Report tracebloc's view of this machine's active client β€” online, offline, +or pending. With --wait, poll until tracebloc reports it online (exit 0) or the +timeout elapses (non-zero), to confirm the client connected after setup. + +Usage: + tracebloc client status [flags] + +Flags: + -h, --help help for status + --timeout duration with --wait, give up after this long (default 2m0s) + --wait poll until tracebloc reports this client online + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/09-cluster.golden b/internal/cli/testdata/golden/09-cluster.golden new file mode 100644 index 00000000..b42e3068 --- /dev/null +++ b/internal/cli/testdata/golden/09-cluster.golden @@ -0,0 +1,71 @@ +tb cluster β€” low-level cluster info +=================================== +What you see under `tb cluster`. `tb cluster info` reads live cluster state, so +it isn't a stable screen; its strings are in zz-all-strings.golden. (`tb cluster +doctor` is the same health check as `tb doctor` β€” see 05-doctor.) + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc cluster --help +Commands for inspecting the Kubernetes cluster the CLI is +configured to talk to. + +Use `cluster info` to verify which cluster, namespace, and +client the next `data ingest` will target. Useful as a +pre-flight before doing anything destructive (e.g. ingesting into +the wrong cluster). + +Usage: + tracebloc cluster [flags] + tracebloc cluster [command] + +Available Commands: + info Show the cluster, namespace, client install, and ingestor token state + +Flags: + -h, --help help for cluster + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) + +Use "tracebloc cluster [command] --help" for more information about a command. + +$ tracebloc cluster info --help +Discovers the tracebloc client installed in the configured +cluster + namespace and prints: + + β€’ Which kubeconfig context the CLI used + β€’ The namespace it resolved to + β€’ The client's release name + chart version + appVersion + β€’ The jobs-manager Service the next data ingest would POST to + β€’ The ingestor ServiceAccount the post-install hook would auth as + β€’ The cluster's configured INGESTOR_IMAGE_DIGEST default + β€’ Whether the user's kubeconfig can mint short-lived SA tokens + via TokenRequest, or has to fall back to a static + service-account-token Secret + +The actual token bytes are never printed; the diagnostic shows +SHA256(token)[:8] so the customer can verify "yes, that's the +token I expect" without leaking it to terminal scrollback. + +Exit codes: + 0 cluster discovered + token mintable; CLI is ready + 4 cluster reachable but no tracebloc client found + 5 cluster reachable + release found but no usable SA token + +Usage: + tracebloc cluster info [flags] + +Flags: + --context string name of the kubeconfig context to use (default: kubeconfig's current-context) + -h, --help help for info + --kubeconfig string path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string namespace where your tracebloc client is installed (default: the context's namespace, or 'default') + --token-expiry-seconds int requested SA token expiration in seconds (default 600 = 10 min; ignored for static-secret fallback) (default 600) + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/10-version.golden b/internal/cli/testdata/golden/10-version.golden new file mode 100644 index 00000000..a01dc573 --- /dev/null +++ b/internal/cli/testdata/golden/10-version.golden @@ -0,0 +1,27 @@ +tb version β€” print the CLI version +================================== +What you see when you run `tb version`. It prints one line: + + tracebloc (, built , on /) + +The go-version and os/arch are filled in at runtime, so the exact line varies by +machine (that's why it isn't pinned byte-exact here). `--output-json` emits the +same fields as indented JSON. Only the --help is byte-exact below. + + +------------------------------------------------------------ +--help +------------------------------------------------------------ +$ tracebloc version --help +Print the tracebloc CLI version, git SHA, and build date + +Usage: + tracebloc version [flags] + +Flags: + -h, --help help for version + --output-json emit the version payload as indented JSON instead of a single human-readable line + +Global Flags: + --plain disable color and decorative output (also honors $NO_COLOR) + --verbose stream detailed step-by-step progress (also via $TRACEBLOC_LOG_LEVEL=debug) diff --git a/internal/cli/testdata/golden/zz-all-strings.golden b/internal/cli/testdata/golden/zz-all-strings.golden new file mode 100644 index 00000000..009e7131 --- /dev/null +++ b/internal/cli/testdata/golden/zz-all-strings.golden @@ -0,0 +1,592 @@ +every user-facing string in the source (AST-harvested β€” all arguments to the +Printer methods + errors.New/fmt.Errorf/fmt.Sprintf, plus the text/remedy +fields of healthLine{} and doctor.Result{} literals, both "…" and `…` raw +strings). The completeness backstop: catches the failure remedies and the +multi-step flows (ingest steps, login, progress, confirmations) not shown as a +screen. %s/%d are runtime placeholders. + +"\"active\" is this machine's selected client; state is its last reported status to tracebloc." +"%-*s%s" +"%-*s%s (%s)" +"%.2f GiB" +"%.2f KiB" +"%.2f MiB" +"%d %s" +"%d B" +"%d annotation(s) without an image (%s)" +"%d files" +"%d files (%s)" +"%d image pull secret(s) present and well-formed" +"%d image(s) without a mask (%s)" +"%d image(s) without an annotation (%s)" +"%d mask(s) not named _mask.png (%s)" +"%d mask(s) without an image (%s)" +"%d of %d" +"%d pod(s), none crash-looping or stuck Pending" +"%d pod(s), none restarted β‰₯%d times" +"%d system table(s) hidden β€” show with --all." +"%dd ago" +"%dh ago" +"%dm ago" +"%q exists but is not a directory" +"%q is a directory, not a file" +"%q is not a directory; pass the directory containing labels.csv + images/" +"%q is not a directory; pass the directory containing labels.csv + the text files" +"%s state=%s namespace=%s location=%s" +"%s %q must be WxH (e.g. 512x512)" +"%s %q: height is not an integer: %w" +"%s %q: width and height must both be positive" +"%s %q: width is not an integer: %w" +"%s %s β€” %s" +"%s (%dx%d)" +"%s (unreadable: %v)" +"%s Bound, mounted at %s" +"%s has a header but no data rows (0 ingestable records). Add at least one data row and re-run." +"%s is empty β€” add a header and at least one data row, then re-run" +"%s is empty β€” no header row" +"%s is image tasks only; it doesn't apply to task %q" +"%s of %s GiB" +"%s of %s cores" +"%s requires CLIENT_WRITE permission" +"%s unreachable: %v" +"%s Β· %d" +"%s Β· Online%s" +"%s Β· can't reach it from here β€” run %s" +"%s Β· running β€” couldn't confirm it's connected to tracebloc β€” run %s" +"%s Β· running, but tracebloc hasn't heard from it β€” run %s" +"%s Β· starting up, not ready yet β€” run %s" +"%s Γ—%d" +"%s, … and %d more" +"%s/%s" +"%s: %w" +"%s=%s,%s=%s" +"%v (policy: %v)" +"(%d CPU Β· %d GiB" +"(+%d more)" +"(Pod phase: %s)" +"(couldn't check whether %q already exists β€” continuing; the cluster still refuses duplicates: %v)" +"(datasets on this client: %s)" +"(last container state: %s β€” %s)" +"(remote tar stderr: %s)" +"(scheduling: %s β€” %s)" +"(table: %s)" +", %s=%s" +"- %s, age %s%s" +"-%02d" +"--label-column doesn't apply to task %q β€” it trains on the text itself, with no label column" +"--number-of-keypoints is keypoint_detection only; it doesn't apply to task %q" +"--overwrite can't be combined with --idempotency-key: a reused key makes the cluster replay the previous run instead of ingesting the new data β€” after --overwrite's removal that would report success while loading nothing. Drop one of the two (a fresh per-run key is the default)." +"--schema is empty; expected col:TYPE,col:TYPE,..." +"--schema is tabular/time-series tasks only; it doesn't apply to task %q" +"--time-column is time_to_event_prediction only; it doesn't apply to task %q" +"--timeout has no effect without --wait" +"0:%d" +"3 GiB" +"A dataset named %q already exists β€” replace it?" +"A real run continues with step 2 (copy into your secure environment) and step 3 (validate and load)." +"A tracebloc client is already running on this cluster β€” adopting it. Couldn't read the cluster identity, so its idempotency anchor was left unchanged; point --kubeconfig/--context at a cluster where kube-system is readable to stamp it." +"A training run is allocated up to:" +"Add --help to any command for the flags." +"Add/resize a node to meet the job's requests, or lower RESOURCE_REQUESTS on jobs-manager." +"Already signed out." +"Applies to your next training run; a run already going keeps its size." +"Ask one of these admins (or ask them to grant you access)" +"CPU cores for one run (1–%d)" +"CSV %s has no columns" +"Can't reach tracebloc from here." +"Cancelled β€” %q was left as-is; nothing was ingested." +"Cancelled β€” nothing was changed." +"Cancelled β€” nothing was deleted." +"Cancelled β€” nothing was ingested." +"Cancelled β€” the name didn't match. Nothing was removed." +"Cancelled." +"Chart uninstall reported: %v" +"Check on it later with: kubectl logs -f -n %s job/%s" +"Check your data" +"Check your network / HTTP(S)_PROXY, then run `%s doctor` again." +"Client install" +"Client status" +"Clients in your account" +"Cluster teardown reported: %v" +"Column types" +"Connected to tracebloc" +"Connecting to your secure environment…" +"Copy into your secure environment" +"Copying %s" +"Correlation id: %s" +"Couldn't check for active training runs (%v) β€” continuing; the confirmation below still guards you." +"Couldn't connect to your secure environment β€” check your kubeconfig/context." +"Couldn't locate the CLI binary to remove it (%v) β€” delete it by hand." +"Couldn't read the target cluster's identity β€” provisioning without a cluster anchor, so re-running won't be idempotent. Point --kubeconfig/--context at the reachable cluster to enable that." +"Couldn't read your tracebloc config β€” run `%s login` to recreate it." +"Couldn't reclaim the temporary copy (%v). It's harmless β€” the next re-ingest of %q or a `tracebloc data delete %s` will clear it." +"Couldn't remove the CLI (%v) β€” remove it by hand: rm -f %s" +"Couldn't remove the CLI (%v). It looks Homebrew-managed β€” finish with: brew uninstall tracebloc" +"Couldn't remove the `tb` alias (%v) β€” remove it by hand: rm -f %s" +"Couldn't save the active-client pointer (%v) β€” re-run `tracebloc client create` (it adopts this cluster's client) to set it." +"Couldn't save the active-client pointer (%v) β€” re-run `tracebloc client create` (it adopts this cluster's client)." +"Couldn't verify your session with the backend (%v)." +"Couldn't write the support bundle: %v" +"Credential written to %s (mode 0600, not shown). This machine is set to enroll as client %d." +"DB failures" +"DROP TABLE IF EXISTS `%s`.`%s`" +"Datasets in %s (0)" +"Datasets in %s β€” %d" +"Delete %q and its files?" +"Deleted %s.%s and %d PVC path(s)." +"Destructive and cannot be undone." +"Detached β€” the ingestion runs in the background on your secure environment." +"Details" +"Details (for support)" +"Diagnose auth / cluster problems with: tracebloc doctor" +"Do you want to change the allocation? Run `%s resources set` (guided walkthrough on a terminal)." +"Do you want to ingest training or test data?" +"Docker and related tools β€” remove them yourself if you no longer need them" +"Dry run β€” nothing was changed" +"Dry-run complete β€” your data and secure environment check out; nothing was created." +"Dry-run β€” nothing was deleted." +"Each training run already uses up to %s β€” nothing to change." +"Each training run may now use up to %s." +"Email it to support@tracebloc.io." +"Email support@tracebloc.io with the output of `%s doctor --diagnose`." +"Ensure your kubeconfig user can list nodes." +"Enter" +"Everything looks good β€” you're ready to run training." +"Follow it later with: kubectl logs -f -n %s job/%s" +"For help: https://docs.tracebloc.io/create-use-case/prepare-dataset" +"Found labels.csv and a %s folder β€” this looks like text data." +"Free some up, or raise the machine's allocation in Docker Desktop β†’ Resources." +"Full log: %s" +"GPU access removed β€” training runs will use CPU only." +"Give the path to a file or a folder β€” whichever holds your data:" +"How many GPUs for one run (1–%d)" +"How many keypoints per sample?" +"How much may one training run use?" +"How much of this machine a training run may use" +"If GPU training is expected, ensure one node has both the compute and the GPU capacity, with its device plugin." +"Image resolution" +"Images a folder with labels.csv + images/ e.g. ~/data/xray/" +"Ingest datasets to your secure environment." +"Ingest settings" +"Ingestion complete β€” %s" +"Ingestion complete β€” showing its logs:" +"Ingestion completed partially β€” %s" +"Ingestion completed with failures β€” %s" +"Ingestion completed with skips β€” %s" +"Ingestion started β€” live progress:" +"Ingestion started β€” streaming logs:" +"Ingestion summary" +"Ingestor SA token" +"Ingests a local dataset into your secure environment's storage,\nsubmits the ingestion run, and follows it to completion (streaming\nprogress + the final summary). Your data never leaves your own\ninfrastructure. Supports %[1]d tasks across the image, text, and\ntabular / time-series families; pick one with --task.\n\n is the data itself. What it looks like depends on the task:\n\n tabular / time-series β€” the dataset is a single CSV. Pass the .csv\n file directly, or a folder holding exactly one .csv:\n\n churn.csv (the .csv file itself)\n or\n churn/\n data.csv (the one .csv in the folder)\n\n image (classification, object/keypoint detection) β€” a folder with\n labels.csv + an images/ subfolder:\n\n cats_dogs/\n labels.csv (required)\n images/ (required)\n 001.jpg\n ...\n\n text (classification, masked language modeling) β€” a folder with\n labels.csv + a %[2]s/ subfolder (masked language modeling uses %[3]s/):\n\n reviews/\n labels.csv (required)\n %[2]s/ (required β€” %[3]s/ for masked language modeling)\n 001.txt\n ...\n\nA bare .csv file is accepted only for the tabular / time-series family;\nimage and text datasets must be a folder.\n\nAccepted image extensions: .jpg, .jpeg, or .png (case-insensitive).\nAll images in one dataset must share a single type β€” the cluster\nvalidates the type it was told to expect.\n\nv0.1 caps the dataset at 1 GiB total + 500 MiB per file. Larger\ndatasets need the v0.2 cloud-source story (S3/GCS/HTTPS sources) β€”\nsee tracebloc/client#147 non-goals.\n\nExit codes:\n 0 files staged + ingested successfully (or --detach: just staged + submitted)\n 2 schema validation failed (synthesized spec rejected) or\n v0.1-unsupported task passed\n 3 local-layout or kubeconfig error\n 4 cluster reachable but no tracebloc client / shared storage missing\n 5 ingestor SA token couldn't be obtained, or jobs-manager\n rejected the token (401/403)\n 6 destination table already exists (re-run with --overwrite to\n replace it, or pick a different --name)\n 7 pre-flight succeeded but staging the files failed\n (Pod creation, image pull, exec stream, or remote tar error) β€”\n or, with --overwrite, removing the old table failed\n 8 jobs-manager rejected the submit (4xx/5xx other than auth)\n 9 ingestion Job exited non-zero, or completed with row-level\n failures the summary panel reports" +"Kept local data and config (~/.tracebloc); cleared the active-client pointer β€” --keep-data." +"Kept on tracebloc" +"Kubeconfig" +"Label policy" +"Left %s in place β€” it isn't tracebloc's `tb` alias." +"Left alone" +"Let each training run use up to %s?" +"Local dataset" +"Machine credential β€” needed by the installer to connect this client" +"Memory" +"Memory for one run in GiB (2–%d)" +"No client in namespace %q β€” using the one in %q (override with --namespace)." +"No clients yet. Run `tracebloc client create`." +"No datasets yet β€” ingest one with `%s data ingest`." +"No new credential issued; the existing one stands. This machine is set to enroll as client %d." +"No problems found, but some checks couldn't finish β€” re-run with --verbose for detail." +"No secure environment on this machine yet β€” run the installer to set one up." +"No secure environment on this machine yet." +"Not connected β€” can't reach tracebloc from here." +"Not connected β€” couldn't read your secure environment." +"Not connected β€” tracebloc didn't confirm your session (server error)." +"Not connected β€” your secure environment isn't answering." +"Not ready β€” dataset storage isn't available." +"Not ready β€” not enough free compute to start a training." +"Not ready β€” part of your secure environment can't start yet." +"Not ready β€” part of your secure environment isn't running." +"Not ready β€” the training images can't be pulled." +"Not signed in yet." +"Not signed in β€” run `%s login`." +"Not signed in. Run `tracebloc login`." +"Not yet in the CLI:" +"Note: %d file(s) in images/ have no labels.csv row and won't be part of the dataset: %s" +"Offboarded %q. This machine is no longer connected to tracebloc." +"Only tracebloc's small jobs-manager restarts β€” running training isn't interrupted." +"Open" +"POST %s%s: %w" +"PVC is %v, not ReadWriteMany β€” the stage Pod will co-locate with the existing mounter" +"Pending > %s: %v" +"Pick this dataset when you set it up." +"Please name the dataset." +"Private image pulls will ImagePullBackOff. Reinstall the chart with valid registry credentials." +"Proceed with the ingest?" +"Provision this client?" +"Provisioned client %q (namespace %s)." +"Provisioning didn't complete. Re-running is safe β€” on the same cluster it adopts the existing client instead of minting a duplicate (idempotent):" +"Reading your files locally first β€” nothing has touched your secure environment yet β€” so a layout or settings problem shows up right away." +"Ready for `tracebloc data ingest`." +"Ready to run training" +"Ready to run training β€” can't check yet" +"Ready to run training β€” couldn't check free compute (run with --verbose)" +"Ready to run training β€” couldn't check your workloads (run with --verbose)" +"Reclaimed tracebloc's downloaded images." +"Recreate it as a docker-registry secret (kubectl create secret docker-registry)." +"Recreate the registry secret; its .dockerconfigjson isn't valid JSON." +"Regression targets are continuous. 'bucket' groups them into ranges before they leave the cluster; 'passthrough' keeps raw values." +"Reinstall with `%s`, or email support@tracebloc.io with `%s doctor --diagnose`." +"Removed local tracebloc data and config." +"Removed stray control characters from the name." +"Removed the local environment." +"Removed the old %q β€” ingesting the new data." +"Removed the tracebloc CLI from this machine." +"Removing in-cluster artifacts…" +"Removing the existing %q first" +"Review" +"Revoked this machine's credential β€” your secure environment %q stays on tracebloc as a record." +"Run '%s --help' for the available commands." +"SELECT '%s',%s,COUNT(*),%s,%s FROM `%s`.`%s`" +"SELECT table_name FROM information_schema.tables WHERE table_schema='%s' ORDER BY table_name" +"Secure environment %q" +"Set one up: %s" +"Sign in to tracebloc" +"Signed in" +"Signed in as %s" +"Signed in as %s." +"Signed in to %q, but this run targets %q β€” run `tracebloc login`." +"Signed in." +"Signed out locally, but couldn't revoke the token server-side (%v). Revoke from the dashboard if this was a shared machine." +"Signed out." +"Signed-in token was rejected by the backend β€” run `tracebloc login`." +"Some pods are stuck starting β€” usually not enough free compute, or a training image that can't be pulled. Free some up in Docker Desktop β†’ Resources, then re-run `%s doctor`; if it persists, email support@tracebloc.io with `%s doctor --diagnose`." +"Some tracebloc images couldn't be reclaimed (harmless) β€” remove them later with `docker rmi $(docker images --filter=reference='ghcr.io/tracebloc/*' --format '{{.Repository}}:{{.Tag}}')`." +"Still stuck? Email support@tracebloc.io with the output of `%s doctor --diagnose`." +"Stopped following after 1 hour β€” the ingestion is still running and will finish on its own." +"Stopped watching β€” the ingestion keeps running on your secure environment." +"Submitted β€” tracebloc is validating your data and loading it into the table." +"Submitting the run β€” with --detach it keeps running on your secure environment after this command returns; the reconnect command is shown below." +"Submitting the run, then following along as tracebloc validates your data and loads it into the table β€” progress streams below." +"System Β· %d" +"Table %q already exists β€” replacing it (table + files)." +"Tabular one CSV file e.g. ~/data/patients.csv" +"Target" +"Target cluster" +"Text a folder with labels.csv + texts/ e.g. ~/data/reviews/" +"The column holding the duration / time-to-event. e.g. time, tenure_days" +"The dataset's catalog metadata is kept as a record on tracebloc, marked unavailable β€” never removed." +"The name you provided was only control characters β€” auto-naming this client instead." +"The number of landmark points each sample is annotated with β€” dataset-specific. e.g. 17 for COCO human pose" +"The size your images already are, as WxH β€” tracebloc checks every image matches and never resizes. Press Enter to read it from your first image. e.g. 224x224" +"The tracebloc CLI (your local data & config are kept β€” --keep-data)" +"This CLI is out of date β€” update it: %s" +"This cluster is already registered as client %q (namespace %s) β€” adopted it." +"This drops the table and removes the files listed above β€” there's no undo. Pass --yes next time to skip this prompt." +"This follows the run for up to an hour; a longer run keeps going on its own (or start it with --detach and check back later)." +"This is irreversible. Type the client name to confirm, or leave blank to cancel." +"This machine's credential β€” so tracebloc can no longer reach it" +"This matches a previous run (same idempotency key) β€” attaching to the run already in progress." +"This permanently removes a dataset you ingested earlier: it drops the table from\nthe cluster and deletes the dataset's files on the shared storage. It can't be\nundone β€” re-ingesting the data is the only way back." +"This secure environment isn't in the signed-in account β€” continuing; if that's unexpected, check you're logged into the right account." +"This will remove" +"Time column" +"To train or benchmark models on it, create a use case at https://ai.tracebloc.io/my-use-cases" +"Training results can't reach tracebloc β€” experiments will stall." +"Try again shortly; if it persists, email support@tracebloc.io with `%s doctor --diagnose`." +"Type %q to offboard this machine" +"Uninstalled tracebloc." +"Use the GPU for training runs?" +"VARCHAR(%d)" +"Validate and load" +"Verify the requests-proxy is wired: kubectl set env deploy/-jobs-manager --list | grep PROXY" +"We couldn't tell from the layout β€” tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/." +"We infer each column's type from your CSV. Press Enter to accept, or type overrides like age:INT,price:FLOAT." +"Welcome to your secure environment for AI, %s πŸ‘‹" +"What kind of data is this?" +"What kind of machine learning task is this data for?" +"What's next" +"Where is your data?" +"Which task?" +"Will delete" +"Wrote a support bundle to ./%s" +"Wrote client id + namespace to %s (no new credential β€” the existing one stands)." +"You don't have permission to %s in this account." +"Your CPU and memory budget is unchanged β€” but this machine has no GPU while the cluster still requests one, so I'll clear that stale GPU setting so runs can schedule." +"Your data is registered as a dataset. View it at https://ai.tracebloc.io/metadata" +"Your dataset records (marked unavailable, not deleted)" +"Your files are copied securely into your secure environment's storage β€” set up and cleaned up for you." +"Your local data & config (~/.tracebloc) and the tracebloc CLI β€” can't be undone" +"Your secure environment %q and everything it runs on this machine" +"Your secure environment is equipped with:" +"Your session expired β€” run `%s login`." +"Your use cases and the models trained here" +"a Ready node can schedule a training job (%s)" +"a dataset path is required" +"a tracebloc client is already running on this cluster, but listing your account to verify ownership failed (%w) β€” re-run once tracebloc is reachable, or resolve manually" +"a tracebloc client is installed in namespace %q but its CLIENT_ID could not be read" +"a training run needs at least %s β€” %s is too little." +"account" +"active client" +"annotations" +"app version" +"authorized β€” confirming the token with the backend …" +"auto-detect" +"backend" +"backend %s β€” requesting a device code …" +"backfilling the cluster anchor onto the existing client: %w" +"bucket" +"bucket bins the target before it leaves the cluster" +"building SPDY transport: %w" +"building rest config from kubeconfig: %w" +"building submit request: %w" +"building tar archive: %w" +"can't read %q: %w" +"cancelled by user" +"chart version" +"checking %s for a byte-order mark: %w" +"client" +"client id" +"closing tar writer: %w" +"cluster" +"columns" +"command" +"connected: %s β€” %s" +"constructing kubernetes clientset: %w" +"context" +"could not check β€” cluster API unreachable (see 'Cluster reachable' above)" +"couldn't read RESOURCE_REQUESTS from jobs-manager β€” skipping node-fit" +"couldn't read capacity: %v" +"couldn't read jobs-manager to resolve image pull secrets β€” skipping" +"couldn't read this machine's capacity: %w" +"cpu=%s, memory=%s" +"crash-looping: %v" +"creating SPDY executor for %s/%s: %w" +"creating credential-file directory: %w" +"creating port-forwarder: %w" +"creating stage Pod in namespace %q: %w" +"creating staging-cleanup pod: %w" +"creating teardown pod: %w" +"csv Β· %d cols" +"dashboard id" +"data CSV" +"data row %d" +"dataset exceeded v0.1 total cap of %s after streaming %s (reached %s)" +"dataset name is required (set --name)" +"dataset name is required β€” pass it as an argument: tracebloc data delete " +"decoding image header %q: %w" +"decoding submit response (got body %q): %w" +"deleting stage Pod %s/%s: %w" +"destination" +"dropping %s.%s: %w%s" +"e.g. 17 for COCO pose" +"e.g. age:INT,price:FLOAT" +"e.g. ~/data/patients.csv or ~/data/xray/" +"enter a whole number between %d and %d" +"exactly %d" +"exec stream against %s/%s: %w" +"exit %d" +"expires" +"expires in" +"field %d is empty β€” every field (%s) must be non-empty" +"file failures" +"full log: %s" +"generating Pod-name random suffix: %w" +"generating idempotency key: %w" +"generating staging-dir suffix: %w" +"how many CPU cores a single training run may use" +"how much memory a single training run may use, in GiB" +"http://%s.%s.svc.cluster.local:%d" +"http://localhost:%d" +"image pull secret %q not found" +"images" +"infer from CSV" +"inferring schema from CSV: %w" +"ingested %s of %s records (%.1f%%)" +"ingestion Job completed but the summary reports failures β€” see panel above" +"ingestion Job exited non-zero β€” see logs above" +"ingestor ID" +"ingestor SA" +"ingestor img" +"inserted" +"intent" +"interactive setup: %w" +"internal: re-parsing synthesized spec: %w\n%s" +"invalid table name %q: %w" +"jobs-manager" +"jobs-manager %s returned HTTP %d: %s" +"jobs-manager has no literal REQUESTS_PROXY_URL (chart too old, or it's set via a configMap/secret ref)" +"jobs-manager: %s" +"keypoints" +"kube-system namespace has no UID" +"kubectl set env deploy/-jobs-manager --list | grep RESOURCE_REQUESTS" +"label column" +"label policy" +"labels.csv" +"letters, digits, and underscores; start with a letter or underscore e.g. churn_train" +"listing Pods for service %s/%s: %w" +"listing chart-managed deployments in namespace %s: %w" +"listing client deployments to check for an existing client: %w" +"listing service-account-token secrets in %s: %w" +"listing stage Pods in %s: %w" +"loading embedded schema: %w" +"loading kubeconfig: %w" +"locating mysql pod: %w" +"location" +"login timed out β€” re-run `tracebloc login`" +"love from tracebloc πŸ’š" +"marshaling submit request: %w" +"marshaling synthesized spec: %w" +"masks" +"min size" +"minting token for ServiceAccount %s/%s via TokenRequest: %w" +"missing %s/ subdirectory in %q" +"must be a positive integer" +"must be between %d and %d" +"mysql -uroot -p\"$MYSQL_ROOT_PASSWORD\" -N -e \"%s\"" +"mysql -uroot -p\"$MYSQL_ROOT_PASSWORD\" -e '%s'" +"mysql table" +"name" +"namespace" +"never (static-secret fallback)" +"no CLI-supported tasks for %s data yet" +"no Ready node can fit a training job (needs %s)" +"no Ready node on this machine to size a training run against" +"no Running pod with name containing %q in namespace %q" +"no active client on this machine β€” nothing to offboard" +"no active client on this machine β€” run `tracebloc client create` (or re-run the installer) first" +"no dataset named %q on this client%s" +"no image files to detect a type from" +"no image pull secret in use (public/digest-pinned images)" +"no single Ready node satisfies cpu+memory AND %s β€” GPU jobs rely on the CPU fallback (needs %s)" +"no such file or directory: %q β€” check the path to your dataset" +"no tracebloc client found" +"none detected" +"not signed in β€” run `tracebloc login` first" +"outcome: early exit before the cluster was probed" +"outcome: early exit β€” no roll-up verdict (granular checks below)" +"overwrite prompt: %w" +"packaging %s: %w" +"packaging labels.csv: %w" +"parsing embedded layout.v1.json: %v" +"password" +"path" +"pick the label/target column from your CSV header" +"pick the task this data is for" +"pod %s container %s restarted %d times" +"port-forward allocated zero ports" +"port-forward to %s/%s failed during startup: %w" +"pvc path" +"querying datasets: %w%s" +"reading %q: %w" +"reading %s header: %w" +"reading %s/: %w" +"reading %s: %w" +"reading CSV header from %s: %w" +"reading CSV row from %s: %w" +"reading PVC %s/%s: %w" +"reading allocated port: %w" +"reading dataset directory %q: %w" +"reading dataset path %q: %w" +"reading final Job status for %s/%s: %w" +"reading images/: %w" +"reading kube-system namespace UID: %w" +"reading labels.csv: %w" +"reading raw kubeconfig: %w" +"reading service %s/%s: %w" +"reading submit response body: %w" +"reading the existing client's identity in namespace %q: %w" +"ready: %s β€” %s" +"refusing to change the ceiling without confirmation: pass --yes, or run on a terminal" +"refusing to delete without confirmation: pass --yes or run on a terminal" +"refusing to offboard without confirmation: pass --yes, or run on a terminal to type the client name" +"refusing to stream symbolic link %q (defense-in-depth; should have been rejected by Discover)" +"release" +"release %q, chart %s, appVersion %s (namespace %s)" +"release: %s (chart %s)" +"removed β€” runs will use CPU only" +"removing PVC paths: %w%s" +"removing staged copy %s: %w%s" +"requests-proxy deployment not found" +"requests-proxy is running, but egress to Service Bus is not actively verified β€” readiness only confirms the relay started, not that it can reach Service Bus" +"requests-proxy not ready (%d/%d replicas)" +"requests-proxy relays training results/metrics to Service Bus; without it, running experiments can't send results back and training stalls mid-run (scheduling is unaffected). Reinstall/upgrade the client chart." +"resolution" +"resolving %q: %w" +"resolving Service %s/%s to a Pod: %w" +"resolving namespace from kubeconfig: %w" +"resource env" +"restarted β‰₯%d times β€” check logs: %v" +"root" +"scanning the cluster for tracebloc clients: %w" +"schema" +"schema entry %q must be col:TYPE (e.g. age:INT,price:FLOAT)" +"secret %q has an empty or malformed %s" +"secret %q is type %q, not %s" +"sent to API" +"server" +"service %s/%s has no selector β€” can't resolve to a Pod for port-forwarding" +"session: %s" +"setting up jobs-manager port-forward: %w" +"sha256[:8]" +"shared PVC" +"shared PVC: %s (%s)" +"sign-in was denied in the browser" +"signed in" +"skipped" +"source" +"stage Pod %s/%s did not become Ready within %s%s" +"stage Pod %s/%s did not reach Ready state: %w%s" +"stage Pod %s/%s terminated in phase %q before becoming Ready%s" +"stat %q: %w" +"stat %s/: %w" +"stat %s: %w" +"stat images/: %w" +"stat labels.csv: %w" +"state" +"status" +"stored active client id %q is not numeric: %w" +"streaming files to %s/%s: %w%s" +"streaming logs from Pod %s/%s: %w" +"submit response missing job_name (got body %q)" +"submit response missing namespace (got body %q)" +"synthesized spec failed schema validation; check the flag values above" +"tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/" +"task" +"task %q isn't a recognized task. Supported tasks: %s." +"task %q isn't supported by the CLI yet%s. Supported tasks: %s." +"teardown failed: %w" +"the client running in this namespace is anchored to a different cluster (%s) than --kubeconfig/--context points at (%s) β€” check you're targeting the right cluster" +"the cluster API server at %s isn't answering β€” is the cluster running?" +"the duration/time column name" +"the label/target column name" +"the sign-in code expired β€” re-run `tracebloc login`" +"the size your images already are; tracebloc checks it, it never resizes" +"this machine has %s, but you asked for %s." +"time" +"time column" +"token saved to ~/.tracebloc (0600)" +"total records" +"total size" +"tracebloc auth" +"tracebloc can see this client." +"tracebloc didn't confirm your session (server error)." +"tracebloc keeps about 1 core and 3 GiB for itself on top of this β€” it fits on this machine." +"tracebloc keeps about 1 core and 3 GiB for itself on top of your choice" +"tracebloc rejected your credentials while waiting β€” run `tracebloc login`, then retry" +"tracebloc rejected your credentials β€” run `tracebloc login`, then retry `tracebloc delete`" +"tracebloc's downloaded images" +"tracebloc-doctor-%s.txt" +"tracebloc-stage-%s-%s" +"train" +"unavailable" +"unknown command %q for %q" +"values:" +"waiting for ingestor Pod: %w" +"waiting for staging-cleanup pod: %w" +"waiting for teardown pod: %w" +"watching ingestor Job: %w" +"which split this data is" +"whole GPUs a single run may use" +"would set each run to" +"writing credential file %s: %w" +"~%s (requested; server may cap shorter)" +"Β· %d GPU" +"Β· %d classes" diff --git a/internal/push/spec.go b/internal/push/spec.go index f7a2c3b0..a6c862d1 100644 --- a/internal/push/spec.go +++ b/internal/push/spec.go @@ -111,13 +111,8 @@ func ValidateTableName(table string) error { } if !tableNamePattern.MatchString(table) { return fmt.Errorf( - "table name %q is invalid: must start with a letter or "+ - "underscore, then letters, digits, and underscores only "+ - "(matches [A-Za-z_][A-Za-z0-9_]*). The table name is "+ - "used both as the MySQL table identifier and as the "+ - "/data/shared/
/ subdirectory on the cluster PVC, "+ - "so a leading digit, slashes, dots, and path-traversal "+ - "sequences are rejected.", + "%q won't work β€” use letters, digits, and underscores, "+ + "starting with a letter or underscore (e.g. churn_train)", table) } return nil diff --git a/internal/submit/summary.go b/internal/submit/summary.go index a52f14b5..f2793401 100644 --- a/internal/submit/summary.go +++ b/internal/submit/summary.go @@ -425,11 +425,23 @@ func RenderSummary(p *ui.Printer, s *Summary) { } p.Field("total records", commaSep(s.TotalRecords)) p.Field("inserted", commaSep(s.InsertedRecords)) - p.Field("sent to API", commaSep(s.APISentRecords)) - p.Field("skipped", commaSep(s.SkippedRecords)) - p.Field("file failures", commaSep(s.FileTransferFailures)) - p.Field("DB failures", commaSep(s.FailedRecords)) - p.Field("success rate", fmt.Sprintf("%.1f%%", s.SuccessRate())) + // Everything below is a SHORTFALL β€” shown only when it actually happened. + // A clean run says it all in the headline (X of Y, %), so zero-row failure + // counters, a "sent to API" that equals inserted, and a redundant + // success-rate line would just be noise. The api-sync shortfall is kept + // because it's the one failure mode the headline (inserted/total) can't show. + if s.APISentRecords < s.InsertedRecords { + p.Field("sent to API", commaSep(s.APISentRecords)) + } + if s.SkippedRecords > 0 { + p.Field("skipped", commaSep(s.SkippedRecords)) + } + if s.FileTransferFailures > 0 { + p.Field("file failures", commaSep(s.FileTransferFailures)) + } + if s.FailedRecords > 0 { + p.Field("DB failures", commaSep(s.FailedRecords)) + } p.Section("What's next") p.Infof("Your data is registered as a dataset. View it at https://ai.tracebloc.io/metadata") diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 0dfd29ba..4f4b0695 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -260,6 +260,14 @@ func (p *Printer) Step(n, total int, label string) { p.out("\n%s %s\n", head, p.paint(label, color.Bold)) } +// PromptStep prints the header for one guided-setup question: a blank line, then +// "Step n of total Β· question" in the heading tone (cyan bold) β€” the dominant +// line. Any supporting hint (Hintf/Infof) and the input prompt render beneath +// it, so the question reads first and the guidance is clearly secondary. +func (p *Printer) PromptStep(n, total int, question string) { + p.out("\n %s\n", p.hue(fmt.Sprintf("Step %d of %d Β· %s", n, total, question), toneHeading)) +} + // Successf prints a completed-item line with a green βœ”. The trailing // `f` + (format, args) signature is Go's convention for "takes a format // string" (cf. fmt.Printf vs fmt.Print). diff --git a/scripts/check-style.sh b/scripts/check-style.sh index e767f564..c21f6f13 100755 --- a/scripts/check-style.sh +++ b/scripts/check-style.sh @@ -7,11 +7,12 @@ # locally: make check-style (or: bash scripts/check-style.sh) # Exit 0 = clean, 1 = violations found, 2 = the guard itself errored (fail-closed). # -# Three mechanical checks (semantic calls β€” role misuse, judgement-y wording β€” -# stay with CODEOWNERS review + STYLE.md; a grep can't police those): -# 1. No hardcoded brand colour outside the colour engine (internal/ui). -# 2. No status / traffic-light emoji β€” the lime dot is the online indicator. -# 3. No 'workspace' in user-facing text β€” the term is "secure environment". +# Two mechanical checks (semantic calls β€” role misuse, judgement-y wording β€” +# stay with CODEOWNERS review + STYLE.md; a grep can't police those). Emoji are +# intentionally NOT policed β€” they're welcome (see STYLE.md): +# 1. No hardcoded brand colour outside the colour engine (internal/ui). New +# output must go through the Printer tones, never a re-hardcoded hex/RGB. +# 2. No 'workspace' in user-facing text β€” the term is "secure environment". # Matched as a whole word, so the exitNoWorkspace code identifier is exempt; # comments and _test.go files are exempt too. # @@ -35,9 +36,9 @@ hits='' # + opt-out lines removed). grep exit 2+ (bad regex/flags/tree) β†’ fail closed. scan() { local re="$1" flags="${2:-}" out rc - # shellcheck disable=SC2086 # No 2>/dev/null: let a real grep error surface on stderr β€” rc>=2 below turns # it into a fail-closed exit, so the error is visible AND fatal, never a silent pass. + # shellcheck disable=SC2086 out="$(grep -rnE $flags --include='*.go' "$re" internal/)" rc=$? if [[ "$rc" -ge 2 ]]; then @@ -64,13 +65,7 @@ scan "$brand" '-i' report "hardcoded brand colour β€” use the Printer tones in ${ENGINE}, don't re-hardcode hex/RGB" \ "$(printf '%s' "$hits" | grep -vE "^${ENGINE}" || true)" -# 2) Status / traffic-light emoji. Pattern built from bytes so this source stays -# emoji-free (green/red/yellow/orange circles). -emoji="$(printf '\360\237\237\242|\360\237\224\264|\360\237\237\241|\360\237\237\240')" -scan "$emoji" -report "status emoji β€” use the lime online dot (see STYLE.md), not traffic-light emoji" "$hits" - -# 3) Banned terminology in user-facing text: 'workspace' -> 'secure environment'. +# 2) Banned terminology in user-facing text: 'workspace' -> 'secure environment'. # -w matches whole words only (exitNoWorkspace is exempt); skip comment lines # (content starts with //, anchored to the file:line: prefix). scan 'workspace' '-iw' diff --git a/scripts/deadcode-allowlist.txt b/scripts/deadcode-allowlist.txt index c4ae1811..ac232768 100644 --- a/scripts/deadcode-allowlist.txt +++ b/scripts/deadcode-allowlist.txt @@ -15,3 +15,12 @@ internal/submit/watch.go: JobOutcome.String # code), so they are unreachable from main by design. internal/push/preflight.go: ReadLabelValues internal/push/tabular.go: inferColumnType +# Retained UI primitive + registry metadata the ingest redesign stopped +# rendering, both still pinned by tests: +# - PromptHeader (bold label before a prompt): the guided ingest flow moved to +# PromptStep, but this stays available for other prompt surfaces (color_matrix_test). +# - CategorySpec.DisplayName: the task picker now lists raw task IDs (approved +# design), but the friendly gloss-over-label metadata is kept β€” pinned by +# preview_test.TestDisplayNameGlosses β€” so re-showing glosses is a one-liner. +internal/ui/ui.go: Printer.PromptHeader +internal/push/category.go: CategorySpec.DisplayName