From 622ad91e260d41628c2671b991629d767383354b Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 05:59:38 +0300 Subject: [PATCH 1/7] P3.4a: dist ci publish happy path + persistence (manifest->store->validate->publish->db.json->JSON) Real local publish success pipeline replacing the ci-publish stub: validate manifest, find/create app + draft release, per-artifact content-address store (P2.2) + common validation (P3.3) with optional manifest-declared size/sha256 (PO ruling), publish via the repo integrity transaction with channel promotion + audit events (P2.3), persist db.json (P2.3), emit stable JSON (release id, artifact ids, sha256, file:// URLs) with --json purity. make publish-example target + tests/publish_happy.sx (fail-before/pass-after). Salvaged from a worker that completed the work (make test 10/10) but hit the 50-min wall before committing; manager-verified at ground truth (make test green, make publish-example exit 0, stored object re-hashes to its key via shasum, db.json records release/2 artifacts/ channel/4 audit events). --- Makefile | 10 +- src/dist.sx | 79 +++++++--- src/manifest/manifest.sx | 26 +++- src/publish/publish.sx | 313 +++++++++++++++++++++++++++++++++++++++ src/repo/db.sx | 6 +- tests/cli_dispatch.sx | 29 +++- tests/publish_happy.sx | 171 +++++++++++++++++++++ 7 files changed, 600 insertions(+), 34 deletions(-) create mode 100644 src/publish/publish.sx create mode 100644 tests/publish_happy.sx diff --git a/Makefile b/Makefile index c074109..453c227 100644 --- a/Makefile +++ b/Makefile @@ -26,9 +26,13 @@ build: test: build @SX="$(SX)" ./tests/run.sh -# Placeholder for the end-to-end publish flow — becomes real in P3.4. -publish-example: - @echo "publish-example: not implemented yet (becomes real in P3.4)" +# End-to-end local publish of examples/dist.json into a fresh .sx-tmp/ +# store, emitting the machine-readable JSON result on stdout. Depends on +# `build` so build/dist exists; the store is reset first so re-runs don't +# collide on the release id. +publish-example: build + @rm -rf .sx-tmp/publish-example + ./$(BUILD_DIR)/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/publish-example --json clean: @rm -rf $(BUILD_DIR) diff --git a/src/dist.sx b/src/dist.sx index 1a17673..9c4a621 100644 --- a/src/dist.sx +++ b/src/dist.sx @@ -2,29 +2,30 @@ // dist.sx — the `dist` distribution CLI entry point (subplan 03, Slice 1). // // Wires the real process argv (via `std.cli`'s `os_args`) to subcommand -// handlers through `cli.parse`. The three groups/commands are dispatched -// to STUB handlers for now — they acknowledge and emit a known result; the -// real publish/promote/rollback logic lands in P3.4/P3.5. +// handlers through `cli.parse`. // -// dist ci publish -// dist release promote -// dist release rollback +// dist ci publish REAL local publish pipeline (P3.4a) — see publish.sx +// dist release promote STUB (real logic lands in P3.5) +// dist release rollback STUB (real logic lands in P3.5) // // EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with // `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing // group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An -// explicit `-h`/`--help` is not an error and ends 0. +// explicit `-h`/`--help` is not an error and ends 0. A failed publish also +// ends `exit_usage()` for now (the failure/abort contract lands in P3.4b). // // `--json` PURITY: every command accepts the reserved global `--json` // flag (surfaced by the parser as `parsed.json`). In json mode stdout // carries ONLY the machine-readable JSON object (emitted via `std.json`, -// isolated in `json_out.sx`); ALL human-readable text (help, progress, -// errors) goes to stderr. In non-json mode human text on stdout is fine. +// isolated in `json_out.sx` / `publish.sx`); ALL human-readable text (help, +// progress, errors) goes to stderr. In non-json mode human text on stdout +// is fine. // ===================================================================== #import "modules/std.sx"; #import "modules/std/cli.sx"; jout :: #import "json_out.sx"; +pl :: #import "publish/publish.sx"; // Direct stderr writer (fd 2), so human help/usage/progress never lands on // stdout's data stream. `out` (std builtin) targets stdout (fd 1). @@ -39,7 +40,7 @@ emit_human :: (s: string, json_mode: bool) { if json_mode { eputs(s); } else { out(s); } } -HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI (stub)\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 64 usage error (no command, or an unknown/missing command or flag)\n"; +HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 64 usage error (no command, or an unknown/missing command or flag)\n"; // True if `name` appears as a token in `args`. has_flag :: (args: []string, name: string) -> bool { @@ -61,13 +62,45 @@ error_phrase :: (e: CliError) -> string { return "usage error"; } -// ── Stub handlers ──────────────────────────────────────────────────── -// Honest stubs: acknowledge the command and emit a known result. NO real -// publish/promote/rollback logic (that is P3.4/P3.5). +// ── Handlers ───────────────────────────────────────────────────────── +// `dist ci publish` — the real local publish pipeline (P3.4a). Runs the +// publish, then renders the outcome: in json mode a single stable JSON +// object on stdout with the human progress note on stderr (the `--json` +// purity contract); otherwise a readable summary on stdout. A failed +// publish prints a diagnostic to stderr and ends with EX_USAGE. handle_ci_publish :: (p: *Parsed, json_mode: bool) { - ack("ci publish", json_mode); + manifest_path := p.value_of("manifest"); + store_dir := p.value_of("local-store"); + + o, e := pl.run_publish(manifest_path, store_dir); + if e { + tag : u32 = xx e; + eputs(concat(concat("dist: ci publish failed: ", error_tag_name(tag)), "\n")); + exit_usage(); + } + + if !e { + if !json_mode { + out(pl.human_summary(@o)); + return; + } + + eputs("dist: ci publish ok\n"); + raw : [16384]u8 = ---; + werr := false; + n := pl.write_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 }; + if werr { + eputs("dist: internal error: JSON serialization failed\n"); + exit_usage(); + } + out(string.{ ptr = @raw[0], len = n }); + out("\n"); + } } + +// ── Stub handlers (real logic lands in P3.5) ───────────────────────── +// Honest stubs: acknowledge the command and emit a known result. handle_release_promote :: (p: *Parsed, json_mode: bool) { ack("release promote", json_mode); } @@ -97,9 +130,8 @@ ack :: (cmd: string, json_mode: bool) { out("\n"); } -// Route a parsed (group, command) to its stub handler. `parse` only -// returns a (group, command) present in the table, so one arm always -// matches. +// Route a parsed (group, command) to its handler. `parse` only returns a +// (group, command) present in the table, so one arm always matches. dispatch :: (p: *Parsed, json_mode: bool) { if p.group == "ci" and p.command == "publish" { handle_ci_publish(p, json_mode); return; } if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; } @@ -131,12 +163,17 @@ main :: () -> ! { } // Command table + flag specs live in this scope; `Parsed` holds VIEWS - // into them, used before `main` returns. Per-command flags arrive with - // the real handlers in P3.4/P3.5 — the global `--json` is recognized by - // the parser without being declared here. + // into them, used before `main` returns. `ci publish` requires + // `--manifest` + `--local-store`; the global `--json` is recognized by + // the parser without being declared here. promote/rollback flags arrive + // with their real handlers in P3.5. no_flags : []FlagSpec = .[]; + publish_flags : []FlagSpec = .[ + FlagSpec.{ name = "manifest", takes_value = true, required = true }, + FlagSpec.{ name = "local-store", takes_value = true, required = true }, + ]; cmds : []Command = .[ - Command.{ group = "ci", command = "publish", flags = no_flags }, + Command.{ group = "ci", command = "publish", flags = publish_flags }, Command.{ group = "release", command = "promote", flags = no_flags }, Command.{ group = "release", command = "rollback", flags = no_flags }, ]; diff --git a/src/manifest/manifest.sx b/src/manifest/manifest.sx index 82fd50d..4c10ae4 100644 --- a/src/manifest/manifest.sx +++ b/src/manifest/manifest.sx @@ -25,6 +25,10 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; +// Also reached through an alias so the json reader is called as `jsonp.parse` +// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share +// one program (the `dist` CLI), which returns a different type. +jsonp :: #import "modules/std/json.sx"; #import "modules/fs.sx"; #import "../domain/platform.sx"; #import "../domain/validate.sx"; @@ -49,12 +53,20 @@ ManifestErr :: error { // One artifact entry. `platform` + `path` are required; `filename`, // `content_type`, and `metadata` are optional overrides that default to "" // when absent. `metadata` is an opaque string mirroring Artifact.metadata. +// +// `size` / `sha256` are OPTIONAL declared expectations the publisher checks +// the on-disk file against: present -> that value IS the expectation; +// absent (`size == -1` / `sha256 == ""`) -> the publisher derives the +// expectation from the stored object, so a no-declaration manifest still +// validates trivially. ManifestArtifact :: struct { platform: Platform; path: string; filename: string; content_type: string; metadata: string; + size: s64 = -1; + sha256: string = ""; } // A single publish: which app/version/channel, and the artifacts that make @@ -107,6 +119,16 @@ opt_str :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) return dup_str(val.str, alloc); } +// Optional integer field. Absent -> `default` (a documented default, not a +// silent one); present-but-not-an-integer -> WrongType. +opt_int :: (o: Object, key: string, default: s64) -> (s64, !ManifestErr) { + v := obj_find(o, key); + if v == null { return default; } + val := v!; + if val != .int_ { raise error.WrongType; } + return val.int_; +} + // Required array field. Absent -> MissingField; present-but-not-an-array -> // WrongType. req_arr :: (o: Object, key: string) -> (Array, !ManifestErr) { @@ -141,6 +163,8 @@ artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !Manif a.filename = try opt_str(o, "filename", alloc); a.content_type = try opt_str(o, "content_type", alloc); a.metadata = try opt_str(o, "metadata", alloc); + a.size = try opt_int(o, "size", -1); + a.sha256 = try opt_str(o, "sha256", alloc); return a; } @@ -151,7 +175,7 @@ artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !Manif // (WrongType), a missing/empty required field (MissingField), and an unknown // artifact platform (UnknownPlatform). All strings are copied into `alloc`. parse_manifest :: (src: string, alloc: Allocator) -> (Manifest, !ManifestErr) { - root, pe := parse(src, alloc); + root, pe := jsonp.parse(src, alloc); if pe { raise error.BadJson; } if root != .object { raise error.WrongType; } ro := root.object; diff --git a/src/publish/publish.sx b/src/publish/publish.sx new file mode 100644 index 0000000..9e53e8b --- /dev/null +++ b/src/publish/publish.sx @@ -0,0 +1,313 @@ +// ===================================================================== +// publish.sx — the local `dist ci publish` SUCCESS pipeline (subplan 03, +// Slice 1). Wires the prior modules into one end-to-end publish: +// +// manifest (P3.2) -> store (P2.2) -> common validation (P3.3) -> +// repository transaction + audit (P2.3) -> db.json persistence (P2.3) +// +// `run_publish(manifest_path, store_dir)` validates the manifest, finds or +// creates the app, drafts a release, content-addresses every artifact into +// `/objects/`, validates each stored file, commits the whole +// aggregate through the integrity-checked repo transaction (channel +// promotion included), records an audit event per upload / publish / +// promotion, persists `/db.json`, and returns a `PublishOutcome` the +// CLI renders as stable JSON or a human summary. +// +// DECLARED-vs-DERIVED EXPECTATIONS (PO ruling): a manifest artifact may +// DECLARE `size` / `sha256`; when it does, that value is the expectation the +// common validation pass checks the on-disk file against. When it does NOT +// (size == -1 / sha256 == ""), the expectation is DERIVED from the stored +// object — the size read off disk and the sha256 returned by the store — so +// a no-declaration manifest validates trivially. (P3.4b will declare a wrong +// size/sha256 to drive the abort path; the repo transaction's rollback is +// left intact for it.) +// +// LOCAL DOWNLOAD URL FORM: `file:///objects/`, where +// is the `--local-store` directory resolved to an absolute path +// (left as-is if already absolute, else joined onto the process cwd). +// +// ALLOCATION: everything the published `Repo` and the returned outcome hold +// is allocated from `context.allocator` (the process-lifetime default), so a +// one-shot publish never frees and never dangles. +// ===================================================================== + +#import "modules/std.sx"; +#import "modules/std/json.sx"; +#import "modules/fs.sx"; +#import "../domain/platform.sx"; +#import "../domain/app.sx"; +#import "../domain/release.sx"; +#import "../domain/artifact.sx"; +#import "../domain/channel.sx"; +#import "../domain/audit.sx"; +#import "../store/store.sx"; +#import "../repo/repo.sx"; +#import "../validation/artifact_file.sx"; +mani :: #import "../manifest/manifest.sx"; +db :: #import "../repo/db.sx"; + +// libc handles the two facts the publish needs from the OS: the wall-clock +// time stamped onto the release / audit events, and the process cwd used to +// absolutize a relative `--local-store` into the download URL. +cstd :: #library "c"; +c_time :: (tloc: *s64) -> s64 #foreign cstd "time"; +c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd"; + +// Failure classes for the publish pipeline. One distinct tag per stage so +// the CLI (and P3.4b) can report precisely where a publish stopped. +// Manifest — the manifest failed to load / parse / validate. +// Store — an artifact's bytes could not be content-addressed. +// Validation — a stored artifact failed the common validation pass. +// Transaction — the repo's integrity-checked publish rejected the aggregate. +// Persist — db.json could not be written. +PublishError :: error { + Manifest, + Store, + Validation, + Transaction, + Persist, +} + +// One artifact as the CLI reports it: its id, platform, size, content +// digest, and the local download URL its bytes live at. +PublishedArtifact :: struct { + id: string; + platform_name: string; + size_bytes: s64; + sha256: string; + url: string; +} + +// The machine-readable result of a successful publish: the release identity +// and the artifacts that were stored under it. +PublishOutcome :: struct { + release_id: string; + app_id: string; + version: string; + channel: string; + artifacts: List(PublishedArtifact); +} + +// ── helpers ─────────────────────────────────────────────────────────── + +// Current wall-clock time as unix epoch seconds (time(2) via a local slot, +// so no null pointer is needed). +now_secs :: () -> s64 { + t : s64 = 0; + return c_time(@t); +} + +// Resolve `dir` to an absolute path: returned unchanged when already +// absolute, else joined onto the process cwd. Falls back to `dir` if the +// cwd can't be read. +abs_store :: (dir: string) -> string { + if dir.len > 0 and dir[0] == 47 { return dir; } // 47='/' already absolute + buf : [4096]u8 = ---; + r := c_getcwd(@buf[0], 4096); + if cast(s64) r == 0 { return dir; } + n := 0; + while buf[n] != 0 { n += 1; } + cwd := string.{ ptr = @buf[0], len = n }; + return path_join(cwd, dir); +} + +// The content type to record when the manifest doesn't declare one — the +// canonical type for the platform's artifact form (each is on the common +// validation allow-list). +default_content_type :: (p: Platform) -> string { + if p == .ios { return "application/octet-stream"; } + if p == .android_apk { return "application/vnd.android.package-archive"; } + if p == .macos { return "application/x-apple-diskimage"; } + if p == .linux { return "application/octet-stream"; } + return "application/x-msdownload"; // windows +} + +// ── pipeline ────────────────────────────────────────────────────────── + +run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !PublishError) { + alloc := context.allocator; + + // 1. Validate the manifest (parse + on-disk artifact existence). + m, me := mani.load_manifest(manifest_path, alloc); + if me { raise error.Manifest; } + + base_dir := dirname(manifest_path); + abs := abs_store(store_dir); + now := now_secs(); + + repo := Repo.init(); + st := Store.init(store_dir); + + // 2. Find or create the app (keyed by slug). + slug := m.app; + app_id := ""; + existing := repo.find_app_by_slug(slug); + if existing == null { + app_id = concat("app-", slug); + repo.create_app(App.{ + id = app_id, slug = slug, display_name = slug, + owner = "ci", visibility = .private, + created_at = now, updated_at = now, + }); + } else { + app_id = existing!.id; + } + + // 3. Draft the release for this version/channel. + release_id := concat(concat(concat("rel-", slug), "-"), m.version); + rel := Release.{ + id = release_id, app_id = app_id, version = m.version, build = 1, + channel = m.channel, notes = "", created_by = "ci", + created_at = now, published_at = now, + }; + + // 4. Per artifact: store by digest -> validate -> build the entity. + arts : List(Artifact) = .{}; + out_arts : List(PublishedArtifact) = .{}; + + i := 0; + while i < m.artifacts.len { + ma := m.artifacts.items[i]; + src := path_join(base_dir, ma.path); + + // Content-address the bytes in memory via `put_bytes`. The streaming + // `put_file` path hashes through `hash.sha256_file`, which the sx + // LLVM backend miscompiles (codegen crash under `sx build`); reading + // the bytes and hashing them in memory yields the identical + // `objects/` key with no streaming hash. + sb := read_file(src); + if sb == null { raise error.Store; } + bytes := sb!; + actual_size := bytes.len; + + key, se := st.put_bytes(bytes); + if se { raise error.Store; } + + // Declared expectation when present; derived from the stored object + // otherwise (so a no-declaration manifest validates trivially). + exp_size := if ma.size >= 0 then ma.size else actual_size; + exp_sha := if ma.sha256.len > 0 then ma.sha256 else key; + ct := if ma.content_type.len > 0 then ma.content_type else default_content_type(ma.platform); + + outcome := validate_artifact_file(src, exp_size, exp_sha, ma.platform, ct); + if outcome.status != .valid { raise error.Validation; } + + fname := if ma.filename.len > 0 then ma.filename else basename(src); + pname := db.platform_str(ma.platform); + + art := Artifact.{ + id = concat(concat(release_id, "-"), pname), + app_id = app_id, release_id = release_id, + platform = ma.platform, filename = fname, content_type = ct, + size_bytes = actual_size, sha256 = key, storage_key = key, + metadata = ma.metadata, validation_status = .valid, + }; + arts.append(art, alloc); + + url := concat(concat(concat("file://", abs), "/objects/"), key); + out_arts.append(PublishedArtifact.{ + id = art.id, platform_name = pname, size_bytes = actual_size, + sha256 = key, url = url, + }, alloc); + + i += 1; + } + + // 5. Publish via the integrity-checked transaction (channel promotion + // included). Rollback on failure is left intact for P3.4b. + chan := Channel.{ + app_id = app_id, name = m.channel, current_release_id = "", + policy = .manual, rollout_percent = 100, + }; + pe := false; + repo.publish(rel, @arts, chan) catch { pe = true; }; + if pe { raise error.Transaction; } + + // Audit trail — only after a committed publish: one upload event per + // artifact, one publish event, one channel-promotion event. + j := 0; + while j < arts.len { + aid := arts.items[j].id; + repo.create_audit_event(AuditEvent.{ + id = concat("evt-upload-", aid), actor = "ci", + action = "artifact.upload", target_type = "artifact", + target_id = aid, metadata = "", created_at = now, + }); + j += 1; + } + repo.create_audit_event(AuditEvent.{ + id = concat("evt-publish-", release_id), actor = "ci", + action = "release.publish", target_type = "release", + target_id = release_id, metadata = "", created_at = now, + }); + repo.create_audit_event(AuditEvent.{ + id = concat(concat(concat("evt-promote-", app_id), "-"), m.channel), + actor = "ci", action = "channel.promote", target_type = "channel", + target_id = m.channel, metadata = "", created_at = now, + }); + + // 6. Persist the whole model under the store. + persist_err := false; + db.save(repo, store_dir) catch { persist_err = true; }; + if persist_err { raise error.Persist; } + + return PublishOutcome.{ + release_id = release_id, app_id = app_id, + version = m.version, channel = m.channel, + artifacts = out_arts, + }; +} + +// ── rendering ───────────────────────────────────────────────────────── + +// Serialize a successful publish as one stable JSON object into the caller +// buffer `dst`, returning the bytes written. Member order is fixed (status, +// release{id,app_id,version,channel}, artifacts[]{id,platform,size_bytes, +// sha256,url}) by `std.json`'s insertion-order guarantee. Overflow surfaces +// on the error channel. +write_json :: (o: *PublishOutcome, dst: []u8) -> (s64, !JsonError) { + gpa := GPA.init(); + root : Object = .{}; + root.put("status", .str("published"), xx gpa); + + rel : Object = .{}; + rel.put("id", .str(o.release_id), xx gpa); + rel.put("app_id", .str(o.app_id), xx gpa); + rel.put("version", .str(o.version), xx gpa); + rel.put("channel", .str(o.channel), xx gpa); + root.put("release", .object(rel), xx gpa); + + arts : Array = .{}; + i := 0; + while i < o.artifacts.len { + a := o.artifacts.items[i]; + ao : Object = .{}; + ao.put("id", .str(a.id), xx gpa); + ao.put("platform", .str(a.platform_name), xx gpa); + ao.put("size_bytes", .int_(a.size_bytes), xx gpa); + ao.put("sha256", .str(a.sha256), xx gpa); + ao.put("url", .str(a.url), xx gpa); + arts.add(.object(ao), xx gpa); + i += 1; + } + root.put("artifacts", .array(arts), xx gpa); + + rootv : Value = .object(root); + n := try write_to_buffer(rootv, dst); + return n; +} + +// A readable one-publish summary for non-json mode: the release line plus +// one indented line per artifact (platform, digest, download URL). +human_summary :: (o: *PublishOutcome) -> string { + s := concat("published release ", o.release_id); + s = concat(s, concat(" (", concat(o.app_id, concat(" ", concat(o.version, ")"))))); + s = concat(s, concat(" on channel ", concat(o.channel, "\n"))); + i := 0; + while i < o.artifacts.len { + a := o.artifacts.items[i]; + s = concat(s, concat(" ", concat(a.platform_name, concat(" ", concat(a.sha256, concat(" ", concat(a.url, "\n"))))))); + i += 1; + } + return s; +} diff --git a/src/repo/db.sx b/src/repo/db.sx index f7e2932..8f0543a 100644 --- a/src/repo/db.sx +++ b/src/repo/db.sx @@ -21,6 +21,10 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; +// Also reached through an alias so the json reader is called as `jsonp.parse` +// — a bare `parse` would bind to `std.cli`'s `parse` once both modules share +// one program (the `dist` CLI), which returns a different type. +jsonp :: #import "modules/std/json.sx"; #import "modules/fs.sx"; #import "../domain/platform.sx"; #import "../domain/app.sx"; @@ -360,7 +364,7 @@ audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) { load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr { oa := repo.own_allocator; - root_val, pe := parse(bytes, scratch); + root_val, pe := jsonp.parse(bytes, scratch); if pe { raise error.Parse; } ro := try req_obj(root_val); diff --git a/tests/cli_dispatch.sx b/tests/cli_dispatch.sx index ec7f2ca..8c5020a 100644 --- a/tests/cli_dispatch.sx +++ b/tests/cli_dispatch.sx @@ -8,10 +8,13 @@ // // 1. no args → human help/usage on STDERR + EX_USAGE (64). // 2. unknown command → human error on STDERR + EX_USAGE (64). -// 3. `ci publish --json` → STDOUT is a SINGLE valid JSON object (parses -// via std.json with no trailing junk); the human acknowledgement is -// on STDERR, never stdout. +// 3. `release promote --json` → STDOUT is a SINGLE valid JSON object +// (parses via std.json with no trailing junk); the human acknowledgement +// is on STDERR, never stdout. (`release promote` is still a stub; the +// real `ci publish` json output is exercised by publish_happy.sx.) // 4. `--help` → lists the `ci` / `release` groups, exits 0. +// 5. `ci publish --json` with NO required flags → EX_USAGE (64), error on +// stderr (the --manifest / --local-store contract). // // `make test` depends on `build`, so `build/dist` exists before this runs; // the relative path resolves from the repo root (the `make test` cwd). @@ -65,27 +68,27 @@ main :: () -> s32 { // ── 3a. `--json` stdout purity: a single valid JSON object, nothing // else. `2>/dev/null` drops the human note so the pipe carries // ONLY stdout; std.json.parse rejects trailing junk. ───────── - if r := proc.run("build/dist ci publish --json 2>/dev/null") { + if r := proc.run("build/dist release promote --json 2>/dev/null") { proc.assert(r.exit_code == 0, "stub --json command must succeed (EX_OK)"); v, e := json.parse(r.stdout, xx gpa); proc.assert(!e, "stdout in --json mode must be a single valid JSON object (parse failed / trailing junk)"); if !e { o := v.object; proc.assert(o.len == 3, "stub json object carries command/status/stub"); - proc.assert(o.items[0].key == "command" and o.items[0].val.str == "ci publish", + proc.assert(o.items[0].key == "command" and o.items[0].val.str == "release promote", "stub json names the dispatched command"); proc.assert(o.items[1].key == "status" and o.items[1].val.str == "ok", "stub json reports status ok"); } } else { - proc.assert(false, "spawn build/dist ci publish --json failed"); + proc.assert(false, "spawn build/dist release promote --json failed"); } // ── 3b. `--json` mode keeps human text on STDERR (not stdout) ────── - if r := proc.run("build/dist ci publish --json 2>&1 1>/dev/null") { + if r := proc.run("build/dist release promote --json 2>&1 1>/dev/null") { proc.assert(r.stdout.len > 0, "--json mode must still emit human text to stderr"); } else { - proc.assert(false, "spawn build/dist ci publish --json (stderr) failed"); + proc.assert(false, "spawn build/dist release promote --json (stderr) failed"); } // ── 4. `--help` lists the ci / release groups, exits 0 ──────────── @@ -97,6 +100,16 @@ main :: () -> s32 { proc.assert(false, "spawn build/dist --help failed"); } + // ── 5. `ci publish` requires --manifest / --local-store ─────────── + // Missing a required flag is a usage error: EX_USAGE (64), human + // diagnostic on stderr (`2>&1 1>/dev/null` captures the stderr text). + if r := proc.run("build/dist ci publish --json 2>&1 1>/dev/null") { + proc.assert(r.exit_code == 64, "ci publish without required flags must exit EX_USAGE (64)"); + proc.assert(contains(r.stdout, "missing required flag"), "missing-flag error names the failure on stderr"); + } else { + proc.assert(false, "spawn build/dist ci publish (no flags) failed"); + } + print("cli_dispatch: ok\n"); return 0; } diff --git a/tests/publish_happy.sx b/tests/publish_happy.sx new file mode 100644 index 0000000..f57aac5 --- /dev/null +++ b/tests/publish_happy.sx @@ -0,0 +1,171 @@ +// Acceptance for P3.4a — the local `dist ci publish` SUCCESS pipeline. +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// cli_dispatch.sx) on `examples/dist.json` into a FRESH `.sx-tmp/` store and +// asserts the end-to-end publish: +// +// 1. exit 0; stdout in `--json` mode is a SINGLE valid JSON object +// (parsed via std.json, no trailing junk). +// 2. the emitted release id / artifact ids / sha256 / local URLs MATCH the +// store: each `/objects/` exists and re-hashes (std.hash) +// to its own key, and each url is `file:///objects/`. +// 3. `/db.json` (re-parsed via std.json) records the release, both +// artifacts (storage_key == sha256, validation_status valid), the +// channel pointer (current_release_id == the release), and an audit +// event per upload/publish/promotion. +// +// This FAILS against the pre-P3.4a stub (which rejects --manifest / +// --local-store as unknown flags, exiting 64, and writes no store) and +// PASSES against the real pipeline. Fresh store per run. +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/process.sx"; +fs :: #import "modules/fs.sx"; +hash :: #import "modules/std/hash.sx"; + +cstd :: #library "c"; +c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd"; + +STORE_REL :: ".sx-tmp/publish_happy"; + +// Process cwd, so the absolute store path / download URLs can be rebuilt +// exactly as the publish does. +cwd :: () -> string { + buf : [4096]u8 = ---; + r := c_getcwd(@buf[0], 4096); + process.assert(cast(s64) r != 0, "getcwd must succeed"); + n := 0; + while buf[n] != 0 { n += 1; } + return substr(string.{ ptr = @buf[0], len = n }, 0, n); +} + +// Fetch a member value by key, asserting presence (the publish output is a +// fixed shape, so an absent key is a hard failure). +get :: (o: Object, key: string) -> Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + process.assert(false, concat("missing json key: ", key)); + dummy : Value = .null_; + return dummy; +} +get_str :: (o: Object, key: string) -> string { return get(o, key).str; } +get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } +get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; } + +// True iff the object at `path` re-hashes (std.hash) to `want` (its key). +rehashes_to :: (path: string, want: string) -> bool { + maybe := hash.sha256_file(path); + if maybe == null { return false; } + d := maybe!; + view := string.{ ptr = @d[0], len = 64 }; + return view == want; +} + +// Count audit events whose "action" equals `action`. +count_action :: (events: Array, action: string) -> s64 { + c : s64 = 0; + i := 0; + while i < events.len { + eo := events.items[i].object; + if get_str(eo, "action") == action { c += 1; } + i += 1; + } + return c; +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + base := cwd(); + store_abs := path_join(base, STORE_REL); + + // Fresh store, even after a crashed prior run. + process.run(concat("rm -rf ", STORE_REL)); + + // ── 1. Run the real publish; stdout must be one JSON object, exit 0 ── + r := process.run("build/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/publish_happy --json 2>/dev/null"); + process.assert(r != null, "spawn build/dist ci publish failed"); + res := r!; + process.assert(res.exit_code == 0, "publish must exit 0 (EX_OK)"); + + v, e := parse(res.stdout, xx arena); + if e { process.assert(false, "stdout must be a single valid JSON object (parse failed / trailing junk)"); return 1; } + root := v.object; + + process.assert(get_str(root, "status") == "published", "status must be published"); + + rel := get_obj(root, "release"); + rel_id := get_str(rel, "id"); + process.assert(rel_id == "rel-acme-app-1.2.3", "release id derived from slug+version"); + process.assert(get_str(rel, "version") == "1.2.3", "release version"); + process.assert(get_str(rel, "channel") == "stable", "release channel"); + print(" release {} published\n", rel_id); + + // ── 2. Each artifact: object exists at objects/, re-hashes to + // its key, and its url is file:///objects/ ── + arts := get_arr(root, "artifacts"); + process.assert(arts.len == 2, "two artifacts published"); + i := 0; + while i < arts.len { + ao := arts.items[i].object; + sha := get_str(ao, "sha256"); + url := get_str(ao, "url"); + process.assert(sha.len == 64, "sha256 is a 64-char digest"); + + obj_path := path_join(STORE_REL, concat("objects/", sha)); + process.assert(fs.exists(obj_path), "object exists at objects/"); + process.assert(rehashes_to(obj_path, sha), "stored object re-hashes to its key"); + + want_url := concat(concat(concat("file://", store_abs), "/objects/"), sha); + process.assert(url == want_url, "url is file:///objects/"); + + process.assert(get_str(ao, "id").len > 0, "artifact id present"); + i += 1; + } + print(" {} artifacts stored + re-hash to their keys\n", arts.len); + + // ── 3. db.json records the published aggregate ────────────────────── + db_bytes := fs.read_file(path_join(STORE_REL, "db.json")); + process.assert(db_bytes != null, "db.json must exist under the store"); + dv, de := parse(db_bytes!, xx arena); + if de { process.assert(false, "db.json must be valid JSON"); return 1; } + dbo := dv.object; + + db_rels := get_arr(dbo, "releases"); + process.assert(db_rels.len == 1, "db: one release"); + process.assert(get_str(db_rels.items[0].object, "id") == rel_id, "db: release id matches"); + + db_arts := get_arr(dbo, "artifacts"); + process.assert(db_arts.len == 2, "db: two artifacts"); + j := 0; + while j < db_arts.len { + dao := db_arts.items[j].object; + process.assert(get_str(dao, "storage_key") == get_str(dao, "sha256"), + "db: storage_key == sha256 (content-addressed)"); + process.assert(get_str(dao, "validation_status") == "valid", + "db: artifact validation passed"); + process.assert(get_str(dao, "release_id") == rel_id, "db: artifact belongs to the release"); + j += 1; + } + + db_chans := get_arr(dbo, "channels"); + process.assert(db_chans.len == 1, "db: one channel"); + ch := db_chans.items[0].object; + process.assert(get_str(ch, "name") == "stable", "db: channel name"); + process.assert(get_str(ch, "current_release_id") == rel_id, "db: channel points at the release"); + + db_events := get_arr(dbo, "audit_events"); + process.assert(count_action(db_events, "artifact.upload") == 2, "db: one upload event per artifact"); + process.assert(count_action(db_events, "release.publish") == 1, "db: one publish event"); + process.assert(count_action(db_events, "channel.promote") == 1, "db: one promotion event"); + print(" db.json records release/artifacts/channel + {} audit events\n", db_events.len); + + process.run(concat("rm -rf ", STORE_REL)); + print("publish_happy: ALL CASES PASS\n"); + return 0; +} From ea2cf14f48eeffe754bf1c140128b0a23dbb873a Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 06:41:11 +0300 Subject: [PATCH 2/7] P3.4a-001: ci publish loads existing db.json (cross-invocation persistence) `dist ci publish` now seeds the Repo from a pre-existing /db.json before find-or-create, so separate CLI invocations share state: a new version accumulates under the single found app, and re-publishing the same release id is rejected by the P2.3 integrity transaction (db.json left unchanged). An absent db.json still starts empty. The loaded model grows through its owning allocator (context.allocator), per the long-lived rule. Wiring db.load into the dist program (which already links manifest.sx) exposed two latent issues, both fixed: - db.sx's load-path helpers (dup_str/obj_find/req_obj/req_arr/ artifact_from_json) collided by name with manifest.sx's same-named helpers; sx resolves bare top-level names across the whole program, so load_into bound to manifest's versions and failed the LoadErr error-set check. Renamed db.sx's five helpers with a db_ prefix (load-path only; save path and public API untouched). - publish's `existing!.id` (only reachable once an app is found, i.e. never before this change) read garbage: sx miscompiles postfix-`!` chained with `.field`. Bound the unwrap to a local first, matching the codebase idiom. tests/publish_persist.sx drives build/dist twice into one store: publish A, then a different version B accumulates (two releases, one app, both objects), then re-publishing A's id fails and leaves db.json unchanged. Fails on the pre-fix write-only persistence, passes after. --- src/publish/publish.sx | 28 ++++-- src/repo/db.sx | 55 +++++++----- tests/publish_persist.sx | 185 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 tests/publish_persist.sx diff --git a/src/publish/publish.sx b/src/publish/publish.sx index 9e53e8b..e0e29f0 100644 --- a/src/publish/publish.sx +++ b/src/publish/publish.sx @@ -5,13 +5,15 @@ // manifest (P3.2) -> store (P2.2) -> common validation (P3.3) -> // repository transaction + audit (P2.3) -> db.json persistence (P2.3) // -// `run_publish(manifest_path, store_dir)` validates the manifest, finds or -// creates the app, drafts a release, content-addresses every artifact into +// `run_publish(manifest_path, store_dir)` validates the manifest, LOADS any +// prior `/db.json` so separate invocations share state (a new version +// accumulates; a duplicate release id is rejected), finds or creates the app, +// drafts a release, content-addresses every artifact into // `/objects/`, validates each stored file, commits the whole // aggregate through the integrity-checked repo transaction (channel // promotion included), records an audit event per upload / publish / -// promotion, persists `/db.json`, and returns a `PublishOutcome` the -// CLI renders as stable JSON or a human summary. +// promotion, persists the merged `/db.json`, and returns a +// `PublishOutcome` the CLI renders as stable JSON or a human summary. // // DECLARED-vs-DERIVED EXPECTATIONS (PO ruling): a manifest artifact may // DECLARE `size` / `sha256`; when it does, that value is the expectation the @@ -59,7 +61,7 @@ c_getcwd :: (buf: [*]u8, size: usize) -> *u8 #foreign cstd "getcwd"; // Store — an artifact's bytes could not be content-addressed. // Validation — a stored artifact failed the common validation pass. // Transaction — the repo's integrity-checked publish rejected the aggregate. -// Persist — db.json could not be written. +// Persist — db.json could not be loaded at startup or written at the end. PublishError :: error { Manifest, Store, @@ -135,7 +137,20 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P abs := abs_store(store_dir); now := now_secs(); + // Seed the Repo from any prior state so separate CLI invocations SHARE + // state through the store: a pre-existing `/db.json` is loaded so + // find-or-create sees earlier apps and the integrity transaction sees + // earlier releases. A new version then ACCUMULATES (the app is found, not + // duplicated); re-publishing the SAME release id is rejected as a + // duplicate by the transaction. An absent db.json starts empty. The loaded + // model grows through its own owning allocator (`context.allocator`, the + // process-lifetime default), per the long-lived-container rule. repo := Repo.init(); + if exists(path_join(store_dir, "db.json")) { + loaded, le := db.load(store_dir); + if le { raise error.Persist; } + repo = loaded; + } st := Store.init(store_dir); // 2. Find or create the app (keyed by slug). @@ -150,7 +165,8 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P created_at = now, updated_at = now, }); } else { - app_id = existing!.id; + found := existing!; + app_id = found.id; } // 3. Draft the release for this version/channel. diff --git a/src/repo/db.sx b/src/repo/db.sx index 8f0543a..479d037 100644 --- a/src/repo/db.sx +++ b/src/repo/db.sx @@ -226,17 +226,24 @@ save :: (self: *Repo, root_dir: string) -> !LoadErr { } // ── read-back helpers (strict; copy strings into `alloc`) ──────────── +// These carry a `db_` prefix because the `dist` program links this module +// alongside `manifest.sx`, which declares its own same-purpose `dup_str` / +// `obj_find` / `req_obj` / `req_arr` / `artifact_from_json`. sx resolves a +// bare top-level name across the WHOLE program (see the `jsonp` note above), +// so an unprefixed name here would bind to manifest's version and mismatch +// this module's `LoadErr` error set. The prefix keeps the load path bound to +// its own helpers. // Copy `s` into `alloc`-owned, null-terminated storage so it survives the // parse scratch / source buffer being freed. -dup_str :: (s: string, alloc: Allocator) -> string { +db_dup_str :: (s: string, alloc: Allocator) -> string { raw : [*]u8 = xx alloc.alloc(s.len + 1); if s.len > 0 { memcpy(raw, s.ptr, s.len); } raw[s.len] = 0; return string.{ ptr = raw, len = s.len }; } -obj_find :: (o: Object, key: string) -> ?Value { +db_obj_find :: (o: Object, key: string) -> ?Value { i := 0; while i < o.len { if o.items[i].key == key { return o.items[i].val; } @@ -247,17 +254,17 @@ obj_find :: (o: Object, key: string) -> ?Value { // Required string field, copied into `alloc`. req_str :: (o: Object, key: string, alloc: Allocator) -> (string, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .str { raise error.BadShape; } - return dup_str(val.str, alloc); + return db_dup_str(val.str, alloc); } // Required string field as a borrowed VIEW (for enum-name parsing only; // not stored, so no copy needed). req_str_view :: (o: Object, key: string) -> (string, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .str { raise error.BadShape; } @@ -265,22 +272,22 @@ req_str_view :: (o: Object, key: string) -> (string, !LoadErr) { } req_int :: (o: Object, key: string) -> (s64, !LoadErr) { - v := obj_find(o, key); + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .int_ { raise error.BadShape; } return val.int_; } -req_arr :: (o: Object, key: string) -> (Array, !LoadErr) { - v := obj_find(o, key); +db_req_arr :: (o: Object, key: string) -> (Array, !LoadErr) { + v := db_obj_find(o, key); if v == null { raise error.BadShape; } val := v!; if val != .array { raise error.BadShape; } return val.array; } -req_obj :: (v: Value) -> (Object, !LoadErr) { +db_req_obj :: (v: Value) -> (Object, !LoadErr) { if v != .object { raise error.BadShape; } return v.object; } @@ -291,10 +298,10 @@ app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) { a.id = try req_str(o, "id", alloc); a.slug = try req_str(o, "slug", alloc); a.display_name = try req_str(o, "display_name", alloc); - bids := try req_arr(o, "bundle_ids"); + bids := try db_req_arr(o, "bundle_ids"); i := 0; while i < bids.len { - bo := try req_obj(bids.items[i]); + bo := try db_req_obj(bids.items[i]); p := try platform_from(try req_str_view(bo, "platform")); bid : BundleId = .{ platform = p, value = try req_str(bo, "value", alloc) }; a.bundle_ids.append(bid, alloc); @@ -321,7 +328,7 @@ release_from_json :: (o: Object, alloc: Allocator) -> (Release, !LoadErr) { return r; } -artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) { +db_artifact_from_json :: (o: Object, alloc: Allocator) -> (Artifact, !LoadErr) { a : Artifact = .{}; a.id = try req_str(o, "id", alloc); a.app_id = try req_str(o, "app_id", alloc); @@ -366,44 +373,44 @@ load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr { root_val, pe := jsonp.parse(bytes, scratch); if pe { raise error.Parse; } - ro := try req_obj(root_val); + ro := try db_req_obj(root_val); - apps_arr := try req_arr(ro, "apps"); + apps_arr := try db_req_arr(ro, "apps"); i := 0; while i < apps_arr.len { - ao := try req_obj(apps_arr.items[i]); + ao := try db_req_obj(apps_arr.items[i]); repo.create_app(try app_from_json(ao, oa)); i += 1; } - rel_arr := try req_arr(ro, "releases"); + rel_arr := try db_req_arr(ro, "releases"); i = 0; while i < rel_arr.len { - o := try req_obj(rel_arr.items[i]); + o := try db_req_obj(rel_arr.items[i]); repo.create_release(try release_from_json(o, oa)); i += 1; } - art_arr := try req_arr(ro, "artifacts"); + art_arr := try db_req_arr(ro, "artifacts"); i = 0; while i < art_arr.len { - o := try req_obj(art_arr.items[i]); - repo.create_artifact(try artifact_from_json(o, oa)); + o := try db_req_obj(art_arr.items[i]); + repo.create_artifact(try db_artifact_from_json(o, oa)); i += 1; } - chan_arr := try req_arr(ro, "channels"); + chan_arr := try db_req_arr(ro, "channels"); i = 0; while i < chan_arr.len { - o := try req_obj(chan_arr.items[i]); + o := try db_req_obj(chan_arr.items[i]); repo.create_channel(try channel_from_json(o, oa)); i += 1; } - ev_arr := try req_arr(ro, "audit_events"); + ev_arr := try db_req_arr(ro, "audit_events"); i = 0; while i < ev_arr.len { - o := try req_obj(ev_arr.items[i]); + o := try db_req_obj(ev_arr.items[i]); repo.create_audit_event(try audit_from_json(o, oa)); i += 1; } diff --git a/tests/publish_persist.sx b/tests/publish_persist.sx new file mode 100644 index 0000000..28a8a2b --- /dev/null +++ b/tests/publish_persist.sx @@ -0,0 +1,185 @@ +// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing +// `/db.json` before publishing, so separate CLI invocations SHARE +// state through the store (not start from an empty Repo and clobber it). +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// publish_happy.sx) twice into ONE store and asserts cross-invocation +// persistence: +// +// 1. Publish version A (1.2.3) into a fresh store → db.json has the release. +// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME +// store → exit 0, and db.json now records BOTH releases under ONE app +// (the app is FOUND, not duplicated); the channel points at the latest +// release B; both content-addressed objects exist. +// 3. Re-publishing the SAME release id (A again) into the same store FAILS +// (exit != 0 — the P2.3 integrity transaction rejects the duplicate +// release id) and leaves db.json UNCHANGED (still two releases). +// +// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads +// db.json and so begins every invocation from an EMPTY Repo) step 2 CLOBBERS +// A — db.json ends with one release, not two — and step 3 "succeeds" (exit 0) +// and overwrites. Both assertions fail. After the load-then-merge fix they +// pass. Fresh store per run. +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/process.sx"; +fs :: #import "modules/fs.sx"; + +STORE :: ".sx-tmp/publish_persist"; +MDIR :: ".sx-tmp/publish_persist_m"; +A_PATH :: ".sx-tmp/publish_persist_m/a.json"; +B_PATH :: ".sx-tmp/publish_persist_m/b.json"; + +// Two manifests for the SAME app/channel/fixtures, differing only in version. +// Artifact paths resolve relative to the manifest's own directory (MDIR), so +// `../../examples/fixtures/...` reaches the repo's committed fixtures. +MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}"; +MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}"; + +// Fetch a member value by key, asserting presence (the publish/db output is a +// fixed shape, so an absent key is a hard failure). +get :: (o: Object, key: string) -> Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + process.assert(false, concat("missing json key: ", key)); + dummy : Value = .null_; + return dummy; +} +get_str :: (o: Object, key: string) -> string { return get(o, key).str; } +get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } +get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; } + +// Count audit events whose "action" equals `action`. +count_action :: (events: Array, action: string) -> s64 { + c : s64 = 0; + i := 0; + while i < events.len { + eo := events.items[i].object; + if get_str(eo, "action") == action { c += 1; } + i += 1; + } + return c; +} + +// True iff `releases` (a db.json array) contains a release with id `id`. +has_release :: (releases: Array, id: string) -> bool { + i := 0; + while i < releases.len { + if get_str(releases.items[i].object, "id") == id { return true; } + i += 1; + } + return false; +} + +// `build/dist ci publish` for `mpath` into the shared store, JSON mode, +// stderr suppressed so stdout stays pure for the parser. +publish_cmd :: (mpath: string) -> string { + c := concat("build/dist ci publish --manifest ", mpath); + c = concat(c, concat(" --local-store ", STORE)); + return concat(c, " --json 2>/dev/null"); +} + +// Write `body` to `path` via the shell (single-quoted, so the JSON's double +// quotes pass through literally). +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +// Parse `/db.json` into its root object (re-read fresh each call). +load_db :: (scratch: Allocator) -> Object { + db_bytes := fs.read_file(path_join(STORE, "db.json")); + process.assert(db_bytes != null, "db.json must exist under the store"); + dv, de := parse(db_bytes!, scratch); + if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; } + return dv.object; +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + // Fresh store + manifest dir, even after a crashed prior run. + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + process.run(concat("mkdir -p ", MDIR)); + write_file(A_PATH, MANIFEST_A); + write_file(B_PATH, MANIFEST_B); + + rel_a := "rel-acme-app-1.2.3"; + rel_b := "rel-acme-app-1.2.4"; + + // ── 1. Publish version A into the fresh store ─────────────────────── + ra := process.run(publish_cmd(A_PATH)); + process.assert(ra != null, "spawn publish A failed"); + res_a := ra!; + process.assert(res_a.exit_code == 0, "publish A must exit 0"); + va, ea := parse(res_a.stdout, xx arena); + if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; } + process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id"); + + db1 := load_db(xx arena); + process.assert(get_arr(db1, "releases").len == 1, "after A: db has one release"); + process.assert(get_arr(db1, "apps").len == 1, "after A: db has one app"); + print(" A published; db has 1 release\n"); + + // ── 2. Publish a DIFFERENT version B into the SAME store ──────────── + rb := process.run(publish_cmd(B_PATH)); + process.assert(rb != null, "spawn publish B failed"); + res_b := rb!; + process.assert(res_b.exit_code == 0, "publish B must exit 0 (accumulates)"); + vb, eb := parse(res_b.stdout, xx arena); + if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; } + process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id"); + + db2 := load_db(xx arena); + + // The crux: BOTH releases under ONE app (app found, not duplicated). + db2_rels := get_arr(db2, "releases"); + process.assert(db2_rels.len == 2, "after B: db records BOTH releases (no clobber)"); + process.assert(has_release(db2_rels, rel_a), "after B: release A still present"); + process.assert(has_release(db2_rels, rel_b), "after B: release B present"); + process.assert(get_arr(db2, "apps").len == 1, "after B: still ONE app (found, not duplicated)"); + + // Four artifacts (two per release); channel promoted to the latest (B). + process.assert(get_arr(db2, "artifacts").len == 4, "after B: four artifacts (two per release)"); + db2_chans := get_arr(db2, "channels"); + process.assert(db2_chans.len == 1, "after B: one channel"); + process.assert(get_str(db2_chans.items[0].object, "current_release_id") == rel_b, + "after B: channel promoted to the latest release"); + + // Each artifact's content-addressed object exists on disk. + db2_arts := get_arr(db2, "artifacts"); + k := 0; + while k < db2_arts.len { + sha := get_str(db2_arts.items[k].object, "sha256"); + process.assert(fs.exists(path_join(STORE, concat("objects/", sha))), + "after B: object exists at objects/"); + k += 1; + } + + // Audit accumulated across both publishes (>= 2 publish events). + process.assert(count_action(get_arr(db2, "audit_events"), "release.publish") == 2, + "after B: one publish event per release"); + print(" B accumulated; db has 2 releases under 1 app\n"); + + // ── 3. Re-publish the SAME release id (A) → duplicate is rejected ─── + rdup := process.run(publish_cmd(A_PATH)); + process.assert(rdup != null, "spawn duplicate publish failed"); + res_dup := rdup!; + process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)"); + + db3 := load_db(xx arena); + process.assert(get_arr(db3, "releases").len == 2, "after duplicate: db UNCHANGED (still two releases)"); + process.assert(get_arr(db3, "apps").len == 1, "after duplicate: still one app"); + print(" duplicate release id rejected; db unchanged\n"); + + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("publish_persist: ALL CASES PASS\n"); + return 0; +} From a93a9a922b41364cbd222d6638fd14904ffe078e Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 11 Jun 2026 22:58:22 +0300 Subject: [PATCH 3/7] sx sync: catch bindings take parens; Allocator.alloc -> alloc_bytes --- src/manifest/manifest.sx | 2 +- src/repo/db.sx | 2 +- tests/domain_validate.sx | 14 +++++++------- tests/manifest_parse.sx | 2 +- tests/repo_transaction.sx | 12 ++++++------ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/manifest/manifest.sx b/src/manifest/manifest.sx index 4c10ae4..fe5e7ce 100644 --- a/src/manifest/manifest.sx +++ b/src/manifest/manifest.sx @@ -83,7 +83,7 @@ Manifest :: struct { // Copy `s` into `alloc`-owned, null-terminated storage so the manifest // survives the source bytes / parse scratch being dropped. dup_str :: (s: string, alloc: Allocator) -> string { - raw : [*]u8 = xx alloc.alloc(s.len + 1); + raw : [*]u8 = xx alloc.alloc_bytes(s.len + 1); if s.len > 0 { memcpy(raw, s.ptr, s.len); } raw[s.len] = 0; return string.{ ptr = raw, len = s.len }; diff --git a/src/repo/db.sx b/src/repo/db.sx index 479d037..f9e9f58 100644 --- a/src/repo/db.sx +++ b/src/repo/db.sx @@ -237,7 +237,7 @@ save :: (self: *Repo, root_dir: string) -> !LoadErr { // Copy `s` into `alloc`-owned, null-terminated storage so it survives the // parse scratch / source buffer being freed. db_dup_str :: (s: string, alloc: Allocator) -> string { - raw : [*]u8 = xx alloc.alloc(s.len + 1); + raw : [*]u8 = xx alloc.alloc_bytes(s.len + 1); if s.len > 0 { memcpy(raw, s.ptr, s.len); } raw[s.len] = 0; return string.{ ptr = raw, len = s.len }; diff --git a/tests/domain_validate.sx b/tests/domain_validate.sx index a5b8391..5423d82 100644 --- a/tests/domain_validate.sx +++ b/tests/domain_validate.sx @@ -108,7 +108,7 @@ check_rejects_bad_slug :: () -> bool { a := valid_app(); a.slug = "Bad_Slug"; // uppercase + underscore matched := false; - validate_app(a) catch e { matched = (e == error.BadSlug); }; + validate_app(a) catch (e) { matched = (e == error.BadSlug); }; return matched; } @@ -116,7 +116,7 @@ check_rejects_empty_version :: () -> bool { r := valid_release(); r.version = ""; matched := false; - validate_release(r) catch e { matched = (e == error.EmptyVersion); }; + validate_release(r) catch (e) { matched = (e == error.EmptyVersion); }; return matched; } @@ -124,7 +124,7 @@ check_rejects_bad_version :: () -> bool { r := valid_release(); r.version = "1.2"; // missing PATCH component matched := false; - validate_release(r) catch e { matched = (e == error.BadVersion); }; + validate_release(r) catch (e) { matched = (e == error.BadVersion); }; return matched; } @@ -137,7 +137,7 @@ check_rejects_bad_channel :: () -> bool { c := valid_channel(); c.name = "Bad Channel"; // space + uppercase matched := false; - validate_channel(c) catch e { matched = (e == error.BadChannelName); }; + validate_channel(c) catch (e) { matched = (e == error.BadChannelName); }; return matched; } @@ -145,7 +145,7 @@ check_rejects_empty_content_type :: () -> bool { a := valid_artifact(); a.content_type = ""; // required string cleared matched := false; - validate_artifact(a) catch e { matched = (e == error.MissingField); }; + validate_artifact(a) catch (e) { matched = (e == error.MissingField); }; return matched; } @@ -153,7 +153,7 @@ check_rejects_bad_size :: () -> bool { a := valid_artifact(); a.size_bytes = -1; // a content-addressed artifact has positive bytes matched := false; - validate_artifact(a) catch e { matched = (e == error.BadSize); }; + validate_artifact(a) catch (e) { matched = (e == error.BadSize); }; return matched; } @@ -161,7 +161,7 @@ check_rejects_bad_digest :: () -> bool { a := valid_artifact(); a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars matched := false; - validate_artifact(a) catch e { matched = (e == error.BadDigest); }; + validate_artifact(a) catch (e) { matched = (e == error.BadDigest); }; return matched; } diff --git a/tests/manifest_parse.sx b/tests/manifest_parse.sx index a3d111c..1e83cfd 100644 --- a/tests/manifest_parse.sx +++ b/tests/manifest_parse.sx @@ -60,7 +60,7 @@ check_missing_artifact_path :: (alloc: Allocator) -> bool { if pe { return false; } // parse must not fail here raised := false; matched := false; - validate_manifest(m, "examples") catch err { raised = true; matched = (err == error.MissingArtifact); }; + validate_manifest(m, "examples") catch (err) { raised = true; matched = (err == error.MissingArtifact); }; return raised and matched; } diff --git a/tests/repo_transaction.sx b/tests/repo_transaction.sx index 4bd5474..ce56cd3 100644 --- a/tests/repo_transaction.sx +++ b/tests/repo_transaction.sx @@ -99,7 +99,7 @@ main :: () -> s32 { arts1.append(mk_artifact("art_01b", "rel_01", "not-a-sha")); // invalid digest failed := false; was_validation := false; - repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch e { + repo.publish(mk_release("rel_01", "1.1.0"), @arts1, the_channel()) catch (e) { failed = true; was_validation = (e == error.Validation); }; @@ -123,7 +123,7 @@ main :: () -> s32 { arts2.append(mk_artifact("art_02", "WRONG", DIGEST_B)); // release_id mismatch ifailed := false; was_integrity := false; - repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch e { + repo.publish(mk_release("rel_02", "1.2.0"), @arts2, the_channel()) catch (e) { ifailed = true; was_integrity = (e == error.Integrity); }; @@ -145,7 +145,7 @@ main :: () -> s32 { arts_xc.append(mk_artifact("art_xc", "rel_xc", DIGEST_B)); // self-consistent artifact xc_failed := false; xc_integrity := false; - repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch e { + repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch (e) { xc_failed = true; xc_integrity = (e == error.Integrity); }; @@ -170,7 +170,7 @@ main :: () -> s32 { arts_xa.append(mk_artifact_for("art_xa", "app_02", "rel_xa", DIGEST_B)); xa_failed := false; xa_integrity := false; - repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch e { + repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch (e) { xa_failed = true; xa_integrity = (e == error.Integrity); }; @@ -196,7 +196,7 @@ main :: () -> s32 { arts_cn.append(mk_artifact("art_cn", "rel_cn", DIGEST_B)); // self-consistent artifact cn_failed := false; cn_integrity := false; - repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch e { + repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch (e) { cn_failed = true; cn_integrity = (e == error.Integrity); }; @@ -222,7 +222,7 @@ main :: () -> s32 { arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B)); dup_failed := false; dup_integrity := false; - repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch e { + repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch (e) { dup_failed = true; dup_integrity = (e == error.Integrity); }; From 59b77729bbf00c5524d3667a53eb6c07c481295d Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 11 Jun 2026 23:00:16 +0300 Subject: [PATCH 4/7] sx sync: migrate legacy modules/{fs,process}.sx imports to modules/std/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The June stdlib restructure deleted the flat library modules; the old paths kept resolving only through a stale zig-out/library install snapshot. Verified green with that snapshot removed. Drop the empty src/infra/ — the planned hash/json/cli shims shipped as sx std modules instead. --- src/infra/.gitkeep | 0 src/manifest/manifest.sx | 2 +- src/publish/publish.sx | 2 +- src/repo/db.sx | 2 +- src/store/store.sx | 2 +- src/validation/artifact_file.sx | 2 +- tests/cli_dispatch.sx | 2 +- tests/publish_happy.sx | 4 ++-- tests/publish_persist.sx | 4 ++-- tests/repo_owns_allocator.sx | 2 +- tests/repo_roundtrip.sx | 4 ++-- tests/repo_transaction.sx | 2 +- tests/store_content_addressed.sx | 4 ++-- 13 files changed, 16 insertions(+), 16 deletions(-) delete mode 100644 src/infra/.gitkeep diff --git a/src/infra/.gitkeep b/src/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/manifest/manifest.sx b/src/manifest/manifest.sx index fe5e7ce..2e2c0ff 100644 --- a/src/manifest/manifest.sx +++ b/src/manifest/manifest.sx @@ -29,7 +29,7 @@ // — a bare `parse` would bind to `std.cli`'s `parse` once both modules share // one program (the `dist` CLI), which returns a different type. jsonp :: #import "modules/std/json.sx"; -#import "modules/fs.sx"; +#import "modules/std/fs.sx"; #import "../domain/platform.sx"; #import "../domain/validate.sx"; diff --git a/src/publish/publish.sx b/src/publish/publish.sx index e0e29f0..106d60c 100644 --- a/src/publish/publish.sx +++ b/src/publish/publish.sx @@ -35,7 +35,7 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; -#import "modules/fs.sx"; +#import "modules/std/fs.sx"; #import "../domain/platform.sx"; #import "../domain/app.sx"; #import "../domain/release.sx"; diff --git a/src/repo/db.sx b/src/repo/db.sx index f9e9f58..6c3908c 100644 --- a/src/repo/db.sx +++ b/src/repo/db.sx @@ -25,7 +25,7 @@ // — a bare `parse` would bind to `std.cli`'s `parse` once both modules share // one program (the `dist` CLI), which returns a different type. jsonp :: #import "modules/std/json.sx"; -#import "modules/fs.sx"; +#import "modules/std/fs.sx"; #import "../domain/platform.sx"; #import "../domain/app.sx"; #import "../domain/release.sx"; diff --git a/src/store/store.sx b/src/store/store.sx index 367d870..1a8727c 100644 --- a/src/store/store.sx +++ b/src/store/store.sx @@ -26,7 +26,7 @@ // ===================================================================== #import "modules/std.sx"; -fs :: #import "modules/fs.sx"; +fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; // Failure classes for a put. `Stage` covers a failed staging write, diff --git a/src/validation/artifact_file.sx b/src/validation/artifact_file.sx index 815e7dc..54b8cb8 100644 --- a/src/validation/artifact_file.sx +++ b/src/validation/artifact_file.sx @@ -23,7 +23,7 @@ // ===================================================================== #import "modules/std.sx"; -#import "modules/fs.sx"; +#import "modules/std/fs.sx"; #import "modules/std/hash.sx"; #import "../domain/platform.sx"; #import "../domain/artifact.sx"; diff --git a/tests/cli_dispatch.sx b/tests/cli_dispatch.sx index 8c5020a..427bbc1 100644 --- a/tests/cli_dispatch.sx +++ b/tests/cli_dispatch.sx @@ -21,7 +21,7 @@ // ===================================================================== #import "modules/std.sx"; -proc :: #import "modules/process.sx"; +proc :: #import "modules/std/process.sx"; json :: #import "modules/std/json.sx"; // True iff `needle` occurs in `hay`. Plain scan — the captured streams are diff --git a/tests/publish_happy.sx b/tests/publish_happy.sx index f57aac5..f04cac8 100644 --- a/tests/publish_happy.sx +++ b/tests/publish_happy.sx @@ -19,8 +19,8 @@ // PASSES against the real pipeline. Fresh store per run. #import "modules/std.sx"; #import "modules/std/json.sx"; -process :: #import "modules/process.sx"; -fs :: #import "modules/fs.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; cstd :: #library "c"; diff --git a/tests/publish_persist.sx b/tests/publish_persist.sx index 28a8a2b..f0a967c 100644 --- a/tests/publish_persist.sx +++ b/tests/publish_persist.sx @@ -22,8 +22,8 @@ // pass. Fresh store per run. #import "modules/std.sx"; #import "modules/std/json.sx"; -process :: #import "modules/process.sx"; -fs :: #import "modules/fs.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; STORE :: ".sx-tmp/publish_persist"; MDIR :: ".sx-tmp/publish_persist_m"; diff --git a/tests/repo_owns_allocator.sx b/tests/repo_owns_allocator.sx index 471fe90..68960cd 100644 --- a/tests/repo_owns_allocator.sx +++ b/tests/repo_owns_allocator.sx @@ -11,7 +11,7 @@ // growth point wrongly used `context.allocator`, the count would not move // (the default allocator is a different, untracked one). #import "modules/std.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; diff --git a/tests/repo_roundtrip.sx b/tests/repo_roundtrip.sx index 2416da6..c85368a 100644 --- a/tests/repo_roundtrip.sx +++ b/tests/repo_roundtrip.sx @@ -11,8 +11,8 @@ // every assertion holds (process.assert aborts otherwise). #import "modules/std.sx"; #import "modules/std/json.sx"; -#import "modules/fs.sx"; -process :: #import "modules/process.sx"; +#import "modules/std/fs.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; diff --git a/tests/repo_transaction.sx b/tests/repo_transaction.sx index ce56cd3..1659991 100644 --- a/tests/repo_transaction.sx +++ b/tests/repo_transaction.sx @@ -19,7 +19,7 @@ // are absent. // Uses a fresh `` under `.sx-tmp/` and cleans up. #import "modules/std.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/domain/platform.sx"; #import "../src/domain/app.sx"; #import "../src/domain/release.sx"; diff --git a/tests/store_content_addressed.sx b/tests/store_content_addressed.sx index 5832028..efdb23c 100644 --- a/tests/store_content_addressed.sx +++ b/tests/store_content_addressed.sx @@ -13,9 +13,9 @@ // 4. put_file — a file source produces the same key and bytes. // Exits 0 only if every assertion holds (process.assert aborts otherwise). #import "modules/std.sx"; -fs :: #import "modules/fs.sx"; +fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; -process :: #import "modules/process.sx"; +process :: #import "modules/std/process.sx"; #import "../src/store/store.sx"; // SHA-256("abc"), the FIPS 180-4 one-block known-answer vector. From 7176e63503c0365c9fe674d363f5b11ad13e7568 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 11 Jun 2026 23:04:10 +0300 Subject: [PATCH 5/7] plan: re-evaluate against current sx (2026-06-11) sx delivered most of the foundation since the plan was written: alias re-exports instead of pub, the modules/std.sx facade barrel, and native std fs/process/json/cli/hash/log/test/socket/mem. Replace the 'sx Foundation Work' section with as-built status + remaining gaps, mark Phase 0 done / Phase 1 partial, note the superseded subplan-01 slices, and drop the retired flow-harness execution contract (subplan 08, roles, checkpoint files). --- .agents/subplans/01-language-and-stdlib.md | 11 ++ .agents/subplans/08-orchestration-and-qa.md | 200 -------------------- .agents/subplans/README.md | 31 +-- PLAN.md | 88 ++++----- 4 files changed, 61 insertions(+), 269 deletions(-) delete mode 100644 .agents/subplans/08-orchestration-and-qa.md diff --git a/.agents/subplans/01-language-and-stdlib.md b/.agents/subplans/01-language-and-stdlib.md index 51e3592..9f85fdd 100644 --- a/.agents/subplans/01-language-and-stdlib.md +++ b/.agents/subplans/01-language-and-stdlib.md @@ -1,5 +1,16 @@ # Subplan 01 - sx Language And Standard Library +> **Status (2026-06-11):** largely superseded by sx itself. Slice 1 shipped +> as-built with a different design — there is no `pub`; aliases are the +> re-export mechanism and `modules/std.sx` is the barrel. Slice 2 is settled +> (`!` error channel, `catch (e)` bindings). Slices 5–7 partially shipped +> (`std.fs`, `std.process`, `std.hash` SHA-256, `std.json`, `std.cli`, +> `std.log`). Still open: Unicode/String model (Slice 3), +> HashMap/StringBuilder (Slice 4), bytes/paths (Slice 5 remainder), +> time/random/encoding (Slice 6 remainder), config (Slice 7 remainder), and +> HTTP/TLS/SQLite/archive/testing helpers (Slice 8). See "sx Foundation +> Status" in `PLAN.md`. + ## Goal Build the language and std primitives required before the distribution platform diff --git a/.agents/subplans/08-orchestration-and-qa.md b/.agents/subplans/08-orchestration-and-qa.md deleted file mode 100644 index 192eed5..0000000 --- a/.agents/subplans/08-orchestration-and-qa.md +++ /dev/null @@ -1,200 +0,0 @@ -# Subplan 08 - Orchestration, Checkpoints, And QA - -## Goal - -Keep agent work resumable, auditable, and constrained. - -## Required Files - -- `.agents/ORCHESTRATION.md` -- `.agents/CHECKPOINT.md` -- `.agents/checkpoint.json` -- `.agents/subplans/README.md` -- `.agents/runs//...` - -## Run Creation - -For each substantial task: - -1. Create `.agents/runs//`. -2. Copy relevant acceptance criteria into `brief.md`. -3. Record the active branch. -4. Record allowed write paths. -5. Update checkpoint before invoking Opus. - -Manager planning sessions count as substantial tasks when the user expects -observability. Create a run record for planning work too, with Codex manager as -the active agent. - -## Agent Liveness - -Each active run should include: - -```txt -.agents/runs// - state.json - agents.json -``` - -`state.json` records: - -- run id -- current phase -- current branch -- input artifact -- input hash -- expected output artifact -- retry count -- next action -- blocker, if any - -`agents.json` records: - -- role -- status: queued, running, completed, failed, dead, restarted -- started_at -- heartbeat_at -- lease_expires_at -- thread id, process id, or tool call id when available -- last_error - -## Status And Progress Tail - -Use the local status command from the workspace root: - -```sh -node .agents/scripts/status.mjs --tail 40 -``` - -For a browser dashboard: - -```sh -node .agents/scripts/observe.mjs --port 4317 -``` - -Then open `http://127.0.0.1:4317`. - -The command reads: - -- `.agents/checkpoint.json` -- every `.agents/runs//state.json` -- every `.agents/runs//agents.json` - -It prints: - -- all known runs -- current phase and branch -- all recorded agents and their lease status -- expired leases -- blockers -- the next action -- the tail of the active run's progress file - -Progress files are checked in this order: - -- `progress.log` -- `implementation-log.md` -- `validation.md` -- `opus-proposal.md` -- `snarky-review.md` - -Managers should append progress events to `progress.log` whenever possible. -Human-readable phase artifacts still stay in their named markdown files. - -## Agent Restart Policy - -If an agent dies, the manager restarts the role from durable files, not memory. - -Snarky restart: - -- Read `PLAN.md`, `.agents/ORCHESTRATION.md`, checkpoint files, and active run - artifacts. -- Re-run the current Snarky phase using the same input artifact. -- Replace only the expected Snarky output for that phase. - -Opus proposal/review restart: - -- Re-run the same `opus-runner` planning tool with the same input artifact. -- Keep previous failed output, if any, as diagnostic context. -- Do not advance until the expected output validates. -- Use a lease and CLI/tool timeout of at least 30 minutes. - -Opus implementation restart: - -- Check current branch. -- Check dirty state. -- If the branch is clean, retry the same implementation instruction. -- If the branch is dirty, manager must inspect the diff and decide whether to - continue, ask Opus to repair, or ask the user. -- Never auto-reset or discard partial Opus edits. -- Use a lease and CLI/tool timeout of at least 30 minutes. - -Retry limits: - -- Retry a dead planning phase up to 2 times. -- Retry an implementation phase up to 1 time without user input. -- After the retry cap, record a blocker in checkpoint files. - -## Checkpoint Policy - -Update checkpoints: - -- at the start of a run -- after Snarky brief -- after Opus proposal -- after concern resolution -- before Opus implementation -- after implementation -- after validation -- before ending the turn - -Checkpoint must include: - -- timestamp -- current phase -- current branch -- active run id -- completed artifacts -- next action -- blockers -- commands/checks already run - -## Validation Layers - -Manager validation: - -- git branch and diff check -- out-of-scope file check -- syntax checks -- unit/integration tests when available -- browser/screenshot checks for UI work when available - -Snarky validation: - -- product requirements -- acceptance criteria -- install flow accuracy -- scope discipline - -Opus validation: - -- layout/design quality -- interaction clarity -- technical design concerns - -## Resume Procedure - -After power loss or interruption: - -1. Read `.agents/CHECKPOINT.md`. -2. Read `.agents/checkpoint.json`. -3. Check git branch and dirty state. -4. Read the active run directory if present. -5. Continue from `next_action`. -6. Do not assume an Opus implementation completed unless validation is recorded. - -## Current Known Setup Issue - -The distribution workspace is not currently a git repository. Branch-based Opus -implementation requires initializing git or moving these files into a repo with -a clean baseline commit. diff --git a/.agents/subplans/README.md b/.agents/subplans/README.md index fc97f0b..fd7fdf1 100644 --- a/.agents/subplans/README.md +++ b/.agents/subplans/README.md @@ -1,31 +1,25 @@ # Detailed Subplan Index -These subplans break `PLAN.md` into implementation slices that can be handed to -Snarky and Opus without reloading the whole plan every time. +These subplans break `PLAN.md` into implementation slices. ## Reading Order -1. `01-language-and-stdlib.md` +1. `01-language-and-stdlib.md` (largely superseded — see "sx Foundation + Status" in `PLAN.md` for the as-built state and remaining gaps) 2. `02-domain-and-storage.md` 3. `03-cli-and-ci.md` 4. `04-http-api-and-install.md` 5. `05-artifact-validation.md` 6. `06-admin-ui.md` 7. `07-packaging-nas.md` -8. `08-orchestration-and-qa.md` -## Session Contract +## Working Contract -- Read `PLAN.md`, `.agents/ORCHESTRATION.md`, `.agents/CHECKPOINT.md`, and - the active subplan before doing work. -- Keep work sequential. Do not use parallel implementation agents. -- Use git branches for implementation. Do not use worktrees. -- For product or UX scope, Snarky writes acceptance criteria and has final say. -- For layout and visual design, Opus has final say. -- For technical problems, consult Opus and resolve by consensus. -- Opus is the only role that writes application code during Opus phases. -- Update `.agents/CHECKPOINT.md` and `.agents/checkpoint.json` after every - completed slice, before any risky handoff, and after validation. +- The multi-agent flow harness is retired; work happens directly in-session. +- Read `PLAN.md` and the active subplan before doing work. The active + milestone slice plan and step progress live in `current/`. +- Implementation is branch-based; `make test` must be green at each step + boundary. ## Slice Exit Criteria @@ -35,10 +29,3 @@ Every slice should finish with: - Tests/checks run listed. - Known risks listed. - Next slice named. -- Checkpoint updated. - -## Current Priority - -The first blocking priority is `01-language-and-stdlib.md`. Product code should -wait until the required `sx` primitives exist or an explicit temporary shim is -approved. diff --git a/PLAN.md b/PLAN.md index b05f9a5..4c1ce0e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -156,48 +156,53 @@ Deployment direction: - UGREEN NAS deployment through Docker/Container Manager is a first-version requirement. -## sx Foundation Work +## sx Foundation Status -Before the product can be implemented well, `sx` needs a stronger language and -standard library foundation. +Re-evaluated 2026-06-11 against the current sx tree. The original foundation +asks have largely landed, with one design difference: sx has no `pub` keyword. +Visibility is import-scoped; aliases are the re-export mechanism +(`print :: core.print`), and a module's namespace tail carries one level into +flat importers. `modules/std.sx` is the curated barrel this plan asked for. -Language/module needs: +Delivered in sx and used by this repo: -- `pub` exports so std modules do not leak private helpers. -- Alias imports and curated namespace barrels. -- Namespace member re-export syntax such as `pub print :: core.print`. -- Error handling that follows the real sx model. `!` is an error channel, not a - generic result wrapper. +- Module system: alias imports, alias re-exports, namespace barrels, + one-level carry, dir-vs-file ambiguity rejection. +- Error handling: the `!` error channel with `raise`/`catch`/`onfail` + (bindings take parens), `?T` optionals. +- `std` modules under `modules/std/`: core, fmt, list, mem (typed allocator + helpers over `alloc_bytes`/`dealloc_bytes`), fs, process, socket (raw TCP), + json, xml, cli, hash (streaming SHA-256), log (leveled, no timestamps yet), + test (bare assert). -Standard library needs, at overview level: +Still missing from sx — the forward wishlist; each item either blocks a later +subplan or has an explicit local workaround: -- Collections: extended `List`, `HashMap` +- Collections: `HashMap` (linear scan over `List` pairs meanwhile). - Strings: validated UTF-8 `String`, `StringBuilder`, explicit Unicode model -- Bytes and paths -- Filesystem and process APIs -- Time, random, hashing, and encoding -- JSON, URL, MIME, config, CLI, and logging -- HTTP server/client and TLS boundary -- SQLite -- Archive inspection -- Testing helpers - -Unicode must be specified precisely: - -- `String` is validated UTF-8 bytes. -- Byte length, scalar values, grapheme clusters, and display width are distinct. -- APIs must clearly say which unit they operate on. -- Invalid UTF-8 must not silently become a `String`. + (byte length, scalar values, grapheme clusters, and display width are + distinct; invalid UTF-8 must not silently become a `String`). +- Bytes and a full path module (only `path_join`/`basename`/`dirname` today). +- Time/clock (publish shims `time(2)` via FFI), random, encodings + (base64url, percent). +- HTTP server/client and TLS boundary — blocks subplan 04 and remote publish + (subplan 03 Slice 3). +- SQLite — blocks subplan 02 Slice 2 (`db.json` stands in). +- Archive inspection — blocks deep IPA/APK validation (subplan 05). +- Config layering (env/file/CLI) and richer testing helpers. ## Implementation Phases -Phase 0 - sx language/module prerequisites: +Phase 0 - sx language/module prerequisites (done, as-built): -- Add `pub` support, alias imports, and namespace member re-exports. +- Delivered in sx via alias re-exports and namespace barrels; there is no + `pub` keyword. -Phase 1 - standard library foundation: +Phase 1 - standard library foundation (partial): -- Build the std primitives needed for CLI, HTTP, storage, validation, and tests. +- Delivered: fs, process, json, cli, hash, log, test, socket, mem. +- Outstanding: HashMap, StringBuilder/Unicode model, time, random, encodings, + HTTP/TLS, SQLite, archive (see "sx Foundation Status"). Phase 2 - product domain: @@ -305,21 +310,10 @@ verifiable from the repo: ## Detailed Execution -`PLAN.md` is the overview. Detailed implementation breakdowns live in -`.agents/subplans/`. +`PLAN.md` is the overview. Slice breakdowns live in `.agents/subplans/` +(01–07). The active milestone slice plan and step progress live in `current/` +(`PLAN.md`, `CHECKPOINT-DISTRIBUTION.md`). -Before starting or resuming work, read: - -- `.agents/ORCHESTRATION.md` -- `.agents/CHECKPOINT.md` -- `.agents/checkpoint.json` -- the active `.agents/subplans/*.md` file - -The workflow is sequential and branch-based: - -- Codex manages orchestration and validation. -- Snarky owns product briefs and final product acceptance. -- Opus owns layout/design decisions. -- Opus is the only role that writes code during Opus implementation phases. -- Implementation uses git branches, not worktrees. -- Checkpoints are updated after every completed slice and before stopping work. +The multi-agent flow harness that originally executed this plan is retired; +work proceeds directly in-session, branch-based, with `make test` green at +each step boundary. From 3c9a15ec80823f2f6b670fdd2c5fcbb305e41ce6 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 12 Jun 2026 00:20:41 +0300 Subject: [PATCH 6/7] P3.4b: loud machine-readable publish failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every abort site writes a CliFailure (stable dotted code + human message naming the offending input); under --json the CLI emits a single {"status":"error","error":{code,message}} object on stdout and exits 1 — distinct from the parser's EX_USAGE 64. All aborts happen before db.save and the repo transaction rolls back, so a failed publish never changes db.json. Pinned test drives all five failure classes plus the non-empty-store no-partial-state crux. --- src/dist.sx | 40 +++++++++--- src/json_out.sx | 27 ++++++++ src/publish/publish.sx | 107 +++++++++++++++++++++++++++---- tests/publish_fail.sx | 142 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 tests/publish_fail.sx diff --git a/src/dist.sx b/src/dist.sx index 9c4a621..c437e94 100644 --- a/src/dist.sx +++ b/src/dist.sx @@ -11,8 +11,10 @@ // EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with // `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing // group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An -// explicit `-h`/`--help` is not an error and ends 0. A failed publish also -// ends `exit_usage()` for now (the failure/abort contract lands in P3.4b). +// explicit `-h`/`--help` is not an error and ends 0. A command that parsed +// correctly but FAILED in execution (publish abort) ends 1 — under `--json` +// the failure is also a single machine-readable error object on stdout +// (`{"status":"error","error":{code,message}}`). // // `--json` PURITY: every command accepts the reserved global `--json` // flag (surfaced by the parser as `parsed.json`). In json mode stdout @@ -40,7 +42,7 @@ emit_human :: (s: string, json_mode: bool) { if json_mode { eputs(s); } else { out(s); } } -HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 64 usage error (no command, or an unknown/missing command or flag)\n"; +HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n"; // True if `name` appears as a token in `args`. has_flag :: (args: []string, name: string) -> bool { @@ -64,20 +66,42 @@ error_phrase :: (e: CliError) -> string { // ── Handlers ───────────────────────────────────────────────────────── +// A command that parsed correctly but failed in execution exits 1 — +// distinct from the parser's EX_USAGE (64), so CI can tell "you called it +// wrong" from "the publish itself aborted". +exit_command_failed :: () -> noreturn { process.exit(1); } + +// Report a failed command and exit 1: the human sentence goes to stderr; +// under `--json` the machine-readable error object is the only thing on +// stdout (the purity contract holds on failure paths too). +report_failure :: (cmd: string, fail: jout.CliFailure, json_mode: bool) -> noreturn { + eputs(concat(concat(concat(concat("dist: ", cmd), " failed: "), fail.message), "\n")); + if json_mode { + raw : [4096]u8 = ---; + werr := false; + n := jout.write_error(fail, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; + if !werr { + out(string.{ ptr = @raw[0], len = n }); + out("\n"); + } + } + exit_command_failed(); +} + // `dist ci publish` — the real local publish pipeline (P3.4a). Runs the // publish, then renders the outcome: in json mode a single stable JSON // object on stdout with the human progress note on stderr (the `--json` // purity contract); otherwise a readable summary on stdout. A failed -// publish prints a diagnostic to stderr and ends with EX_USAGE. +// publish reports through `report_failure` (stderr sentence, JSON error +// object under --json, exit 1). handle_ci_publish :: (p: *Parsed, json_mode: bool) { manifest_path := p.value_of("manifest"); store_dir := p.value_of("local-store"); - o, e := pl.run_publish(manifest_path, store_dir); + fail : jout.CliFailure = .{}; + o, e := pl.run_publish(manifest_path, store_dir, @fail); if e { - tag : u32 = xx e; - eputs(concat(concat("dist: ci publish failed: ", error_tag_name(tag)), "\n")); - exit_usage(); + report_failure("ci publish", fail, json_mode); } if !e { diff --git a/src/json_out.sx b/src/json_out.sx index 6af9e25..c3bc2da 100644 --- a/src/json_out.sx +++ b/src/json_out.sx @@ -9,6 +9,33 @@ #import "modules/std.sx"; #import "modules/std/json.sx"; +// A machine-readable command failure: a stable dotted `code` naming the +// failing stage and sub-reason (e.g. "validation.digest_mismatch") plus a +// human sentence. Pipelines fill one of these at the raise site, so the +// CLI can report precisely what failed without the error channel having to +// carry data. +CliFailure :: struct { + code: string = ""; + message: string = ""; +} + +// Serialize a failure as the `--json` error object — `{ "status": "error", +// "error": { "code": , "message": } }` — into the +// caller-owned `dst`, returning the bytes written. Overflow surfaces on +// the error channel. +write_error :: (f: CliFailure, dst: []u8) -> (s64, !JsonError) { + gpa := GPA.init(); + obj : Object = .{}; + obj.put("status", .str("error"), xx gpa); + eo : Object = .{}; + eo.put("code", .str(f.code), xx gpa); + eo.put("message", .str(f.message), xx gpa); + obj.put("error", .object(eo), xx gpa); + root : Value = .object(obj); + n := try write_to_buffer(root, dst); + return n; +} + // Serialize a stub command's machine result — `{ "command": , // "status": "ok", "stub": true }` — into the caller-owned `dst`, returning // the number of bytes written. Overflow surfaces on the error channel. diff --git a/src/publish/publish.sx b/src/publish/publish.sx index 106d60c..5766bb2 100644 --- a/src/publish/publish.sx +++ b/src/publish/publish.sx @@ -1,6 +1,6 @@ // ===================================================================== -// publish.sx — the local `dist ci publish` SUCCESS pipeline (subplan 03, -// Slice 1). Wires the prior modules into one end-to-end publish: +// publish.sx — the local `dist ci publish` pipeline (subplan 03, Slice 1). +// Wires the prior modules into one end-to-end publish: // // manifest (P3.2) -> store (P2.2) -> common validation (P3.3) -> // repository transaction + audit (P2.3) -> db.json persistence (P2.3) @@ -20,9 +20,13 @@ // common validation pass checks the on-disk file against. When it does NOT // (size == -1 / sha256 == ""), the expectation is DERIVED from the stored // object — the size read off disk and the sha256 returned by the store — so -// a no-declaration manifest validates trivially. (P3.4b will declare a wrong -// size/sha256 to drive the abort path; the repo transaction's rollback is -// left intact for it.) +// a no-declaration manifest validates trivially. +// +// FAILURE CONTRACT (P3.4b): every abort happens BEFORE `db.save`, and the +// repo transaction rolls itself back, so a failed publish never changes +// db.json — no partially-published release, no moved channel pointer. Each +// raise site first writes a `jout.CliFailure` (stable dotted code + human +// message naming the offending input) for the CLI to report. // // LOCAL DOWNLOAD URL FORM: `file:///objects/`, where // is the `--local-store` directory resolved to an absolute path @@ -47,6 +51,7 @@ #import "../validation/artifact_file.sx"; mani :: #import "../manifest/manifest.sx"; db :: #import "../repo/db.sx"; +jout :: #import "../json_out.sx"; // libc handles the two facts the publish needs from the OS: the wall-clock // time stamped onto the release / audit events, and the process cwd used to @@ -124,14 +129,58 @@ default_content_type :: (p: Platform) -> string { return "application/x-msdownload"; // windows } +// ── failure detail ──────────────────────────────────────────────────── +// The error channel carries only a tag; the precise reason (which manifest +// field, which validation check, which artifact) is written into the +// caller's `jout.CliFailure` at the raise site, as a stable dotted code + +// a human sentence naming the offending input where one exists. + +manifest_code :: (e: mani.ManifestErr) -> string { + if e == error.BadJson { return "manifest.bad_json"; } + if e == error.WrongType { return "manifest.wrong_type"; } + if e == error.MissingField { return "manifest.missing_field"; } + if e == error.UnknownPlatform { return "manifest.unknown_platform"; } + if e == error.MissingArtifact { return "manifest.missing_artifact"; } + return "manifest.io"; +} + +manifest_message :: (e: mani.ManifestErr, path: string) -> string { + if e == error.BadJson { return concat("manifest is not valid JSON: ", path); } + if e == error.WrongType { return concat("manifest field has the wrong JSON type: ", path); } + if e == error.MissingField { return concat("manifest is missing a required field: ", path); } + if e == error.UnknownPlatform { return concat("manifest names an unknown platform id: ", path); } + if e == error.MissingArtifact { return concat("manifest references an artifact file that does not exist: ", path); } + return concat("manifest file could not be read: ", path); +} + +validation_code :: (r: ValidationReason) -> string { + if r == .missing_file { return "validation.missing_file"; } + if r == .size_mismatch { return "validation.size_mismatch"; } + if r == .digest_mismatch { return "validation.digest_mismatch"; } + if r == .content_type_denied { return "validation.content_type_denied"; } + return "validation.extension_mismatch"; +} + +validation_message :: (r: ValidationReason, path: string) -> string { + if r == .missing_file { return concat("artifact file disappeared before validation: ", path); } + if r == .size_mismatch { return concat("artifact size does not match the declared size: ", path); } + if r == .digest_mismatch { return concat("artifact sha256 does not match the declared digest: ", path); } + if r == .content_type_denied { return concat("artifact content type is not on the allow-list: ", path); } + return concat("artifact extension does not match its platform: ", path); +} + // ── pipeline ────────────────────────────────────────────────────────── -run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !PublishError) { +run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) { alloc := context.allocator; // 1. Validate the manifest (parse + on-disk artifact existence). m, me := mani.load_manifest(manifest_path, alloc); - if me { raise error.Manifest; } + if me { + fail_out.code = manifest_code(me); + fail_out.message = manifest_message(me, manifest_path); + raise error.Manifest; + } base_dir := dirname(manifest_path); abs := abs_store(store_dir); @@ -148,7 +197,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P repo := Repo.init(); if exists(path_join(store_dir, "db.json")) { loaded, le := db.load(store_dir); - if le { raise error.Persist; } + if le { + fail_out.code = "persist.load"; + fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir); + raise error.Persist; + } repo = loaded; } st := Store.init(store_dir); @@ -192,12 +245,20 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P // the bytes and hashing them in memory yields the identical // `objects/` key with no streaming hash. sb := read_file(src); - if sb == null { raise error.Store; } + if sb == null { + fail_out.code = "store.read"; + fail_out.message = concat("artifact file could not be read: ", src); + raise error.Store; + } bytes := sb!; actual_size := bytes.len; key, se := st.put_bytes(bytes); - if se { raise error.Store; } + if se { + fail_out.code = "store.write"; + fail_out.message = concat("artifact bytes could not be content-addressed into the store: ", src); + raise error.Store; + } // Declared expectation when present; derived from the stored object // otherwise (so a no-declaration manifest validates trivially). @@ -206,7 +267,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P ct := if ma.content_type.len > 0 then ma.content_type else default_content_type(ma.platform); outcome := validate_artifact_file(src, exp_size, exp_sha, ma.platform, ct); - if outcome.status != .valid { raise error.Validation; } + if outcome.status != .valid { + fail_out.code = validation_code(outcome.reason); + fail_out.message = validation_message(outcome.reason, src); + raise error.Validation; + } fname := if ma.filename.len > 0 then ma.filename else basename(src); pname := db.platform_str(ma.platform); @@ -236,8 +301,18 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P policy = .manual, rollout_percent = 100, }; pe := false; - repo.publish(rel, @arts, chan) catch { pe = true; }; - if pe { raise error.Transaction; } + integ := false; + repo.publish(rel, @arts, chan) catch (e) { pe = true; integ = (e == error.Integrity); }; + if pe { + if integ { + fail_out.code = "transaction.integrity"; + fail_out.message = concat("publish rejected: the release/artifact/channel aggregate is identity-inconsistent (e.g. duplicate release id): ", release_id); + } else { + fail_out.code = "transaction.validation"; + fail_out.message = concat("publish rejected: the release, an artifact, or the channel failed domain validation: ", release_id); + } + raise error.Transaction; + } // Audit trail — only after a committed publish: one upload event per // artifact, one publish event, one channel-promotion event. @@ -265,7 +340,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P // 6. Persist the whole model under the store. persist_err := false; db.save(repo, store_dir) catch { persist_err = true; }; - if persist_err { raise error.Persist; } + if persist_err { + fail_out.code = "persist.save"; + fail_out.message = concat("db.json could not be written under the store: ", store_dir); + raise error.Persist; + } return PublishOutcome.{ release_id = release_id, app_id = app_id, diff --git a/tests/publish_fail.sx b/tests/publish_fail.sx new file mode 100644 index 0000000..8247326 --- /dev/null +++ b/tests/publish_fail.sx @@ -0,0 +1,142 @@ +// Pinned acceptance for P3.4b — failure paths are loud and machine-readable. +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// publish_persist.sx) through every publish failure class the slice plan +// names — a malformed manifest, a missing artifact file, an unknown +// platform id, a declared-size mismatch, and a declared-sha256 mismatch — +// and asserts the P3.4b contract for each: +// +// * exit code 1 (command failed; NOT the parser's EX_USAGE 64), +// * stdout under `--json` is a SINGLE JSON object +// `{"status":"error","error":{"code":,"message":...}}`, +// * nothing is persisted: a fresh store gains no db.json. +// +// The no-partial-state crux is then asserted against a NON-EMPTY store: +// publish version A successfully, fail version B on a digest mismatch into +// the SAME store, and require db.json byte-state unchanged — one release, +// channel still pointing at A (no partially-published release, no moved +// channel pointer). +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; + +STORE :: ".sx-tmp/publish_fail"; +MDIR :: ".sx-tmp/publish_fail_m"; + +// Manifests share the committed 5-byte fixtures; paths resolve relative to +// the manifest's own directory (MDIR), so ../../examples/fixtures reaches +// them. +GOOD_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +BAD_DIGEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}"; +BAD_SIZE :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}"; +BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}"; +NOT_JSON :: "this is not a manifest"; + +get :: (o: Object, key: string) -> Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + process.assert(false, concat("missing json key: ", key)); + dummy : Value = .null_; + return dummy; +} +get_str :: (o: Object, key: string) -> string { return get(o, key).str; } +get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } +get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; } + +publish_cmd :: (mpath: string, store: string) -> string { + c := concat("build/dist ci publish --manifest ", mpath); + c = concat(c, concat(" --local-store ", store)); + return concat(c, " --json 2>/dev/null"); +} + +// Write `body` to `path` via the shell (single-quoted, so the JSON's double +// quotes pass through literally). +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +// Run one failing publish and assert the full failure contract: exit 1, +// stdout is exactly one JSON error object, and its error.code equals +// `want_code`. `store` distinguishes the per-case fresh stores. +assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) { + r := process.run(publish_cmd(mpath, store)); + process.assert(r != null, concat("spawn failed: ", label)); + res := r!; + process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label)); + + v, e := parse(res.stdout, scratch); + if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; } + o := v.object; + process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label)); + eo := get_obj(o, "error"); + process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label)); + process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label)); + out(concat(concat(" ", label), ": exit 1 + JSON error ok\n")); +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + process.run(concat("mkdir -p ", MDIR)); + + write_file(path_join(MDIR, "good_a.json"), GOOD_A); + write_file(path_join(MDIR, "bad_digest.json"), BAD_DIGEST); + write_file(path_join(MDIR, "bad_size.json"), BAD_SIZE); + write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM); + write_file(path_join(MDIR, "no_artifact.json"), NO_ARTIFACT); + write_file(path_join(MDIR, "not_json.json"), NOT_JSON); + + // ── each failure class: exit 1 + the precise dotted code, into a + // fresh per-case store that must gain NO db.json ──────────────── + assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena); + assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena); + assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena); + assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena); + assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena); + + process.assert(!fs.exists(concat(STORE, "-digest/db.json")), + "failed publish into a fresh store must not create db.json"); + + // ── no-partial-state against a NON-EMPTY store ──────────────────── + // Publish A successfully, then fail B on a digest mismatch into the + // SAME store: db.json must be unchanged (one release, channel → A). + ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE)); + process.assert(ra != null, "spawn publish A failed"); + process.assert(ra!.exit_code == 0, "publish A must exit 0"); + + rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE)); + process.assert(rb != null, "spawn failing publish B failed"); + process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)"); + + db_bytes := fs.read_file(path_join(STORE, "db.json")); + process.assert(db_bytes != null, "db.json from publish A must still exist"); + dv, de := parse(db_bytes!, xx arena); + if de { process.assert(false, "db.json must be valid JSON"); return 1; } + dbo := dv.object; + process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)"); + chans := get_arr(dbo, "channels"); + process.assert(chans.len == 1, "after failed B: one channel"); + process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3", + "after failed B: channel still points at A (no moved pointer)"); + print(" non-empty store: failed publish left db.json unchanged\n"); + + process.run(concat("rm -rf ", concat(STORE, "-digest"))); + process.run(concat("rm -rf ", concat(STORE, "-size"))); + process.run(concat("rm -rf ", concat(STORE, "-platform"))); + process.run(concat("rm -rf ", concat(STORE, "-missing"))); + process.run(concat("rm -rf ", concat(STORE, "-badjson"))); + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("publish_fail: ALL CASES PASS\n"); + return 0; +} From 93372ea4f0cbc6eaa22cc08ac262ab141561845c Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 12 Jun 2026 00:27:20 +0300 Subject: [PATCH 7/7] P3.5: release promote / rollback over the persisted store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote points an (app, channel) at a release id — cross-channel promotion allowed, missing channel created, manual policy gate stubbed. rollback moves the pointer to the previous PUBLISHED release in the channel's publish-order lineage (cross-promoted pointer falls back to the channel's own latest; at the earliest release it refuses with rollback.no_previous). Both append a cli-actor audit event and re-persist db.json; failures follow the P3.4b contract (dotted-code JSON error, exit 1, store untouched). Acceptance pinned in tests/release_ops.sx; cli_dispatch reworked off the removed stubs. --- src/dist.sx | 105 ++++++++++----- src/json_out.sx | 14 -- src/release/ops.sx | 298 ++++++++++++++++++++++++++++++++++++++++++ tests/cli_dispatch.sx | 48 ++++--- tests/release_ops.sx | 182 ++++++++++++++++++++++++++ 5 files changed, 580 insertions(+), 67 deletions(-) create mode 100644 src/release/ops.sx create mode 100644 tests/release_ops.sx diff --git a/src/dist.sx b/src/dist.sx index c437e94..90ad89c 100644 --- a/src/dist.sx +++ b/src/dist.sx @@ -4,9 +4,9 @@ // Wires the real process argv (via `std.cli`'s `os_args`) to subcommand // handlers through `cli.parse`. // -// dist ci publish REAL local publish pipeline (P3.4a) — see publish.sx -// dist release promote STUB (real logic lands in P3.5) -// dist release rollback STUB (real logic lands in P3.5) +// dist ci publish local publish pipeline (P3.4a/b) — see publish.sx +// dist release promote point a channel at a release (P3.5) — see release/ops.sx +// dist release rollback channel pointer to the previous release (P3.5) // // EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with // `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing @@ -28,6 +28,7 @@ #import "modules/std/cli.sx"; jout :: #import "json_out.sx"; pl :: #import "publish/publish.sx"; +ops :: #import "release/ops.sx"; // Direct stderr writer (fd 2), so human help/usage/progress never lands on // stdout's data stream. `out` (std builtin) targets stdout (fd 1). @@ -42,7 +43,7 @@ emit_human :: (s: string, json_mode: bool) { if json_mode { eputs(s); } else { out(s); } } -HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n"; +HELP :: "dist — application distribution CLI\n\nUsage:\n dist [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest publish manifest (dist.json) to read\n --local-store local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app app the channel belongs to\n --channel channel to move\n --release release id to promote\n --local-store local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app app the channel belongs to\n --channel channel to roll back\n --local-store local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n"; // True if `name` appears as a token in `args`. has_flag :: (args: []string, name: string) -> bool { @@ -123,35 +124,60 @@ handle_ci_publish :: (p: *Parsed, json_mode: bool) { } } -// ── Stub handlers (real logic lands in P3.5) ───────────────────────── -// Honest stubs: acknowledge the command and emit a known result. +// `dist release promote` — point an (app, channel) at a release over the +// persisted store (P3.5). Same rendering contract as publish: JSON object +// on stdout under --json (human note on stderr), readable summary +// otherwise, `report_failure` on abort. handle_release_promote :: (p: *Parsed, json_mode: bool) { - ack("release promote", json_mode); + fail : jout.CliFailure = .{}; + o, e := ops.run_promote(p.value_of("local-store"), p.value_of("app"), + p.value_of("channel"), p.value_of("release"), @fail); + if e { + report_failure("release promote", fail, json_mode); + } + if !e { + if !json_mode { + out(ops.promote_human(@o)); + return; + } + eputs("dist: release promote ok\n"); + raw : [4096]u8 = ---; + werr := false; + n := ops.write_promote_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; + if werr { + eputs("dist: internal error: JSON serialization failed\n"); + exit_command_failed(); + } + out(string.{ ptr = @raw[0], len = n }); + out("\n"); + } } + +// `dist release rollback` — move an (app, channel) back to its previous +// published release (P3.5). Rendering contract as above. handle_release_rollback :: (p: *Parsed, json_mode: bool) { - ack("release rollback", json_mode); -} - -// Emit a stub command's acknowledgement. In json mode: a single JSON -// object on stdout (and a human progress note on stderr). Otherwise: a -// human acknowledgement on stdout. -ack :: (cmd: string, json_mode: bool) { - if !json_mode { - out(concat(concat("dist: ", cmd), " (stub) ok\n")); - return; + fail : jout.CliFailure = .{}; + o, e := ops.run_rollback(p.value_of("local-store"), p.value_of("app"), + p.value_of("channel"), @fail); + if e { + report_failure("release rollback", fail, json_mode); } - - eputs(concat(concat("dist: ", cmd), " (stub) acknowledged\n")); - - raw : [4096]u8 = ---; - werr := false; - n := jout.write_stub(cmd, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; - if werr { - eputs("dist: internal error: JSON serialization failed\n"); - exit_usage(); + if !e { + if !json_mode { + out(ops.rollback_human(@o)); + return; + } + eputs("dist: release rollback ok\n"); + raw : [4096]u8 = ---; + werr := false; + n := ops.write_rollback_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; + if werr { + eputs("dist: internal error: JSON serialization failed\n"); + exit_command_failed(); + } + out(string.{ ptr = @raw[0], len = n }); + out("\n"); } - out(string.{ ptr = @raw[0], len = n }); - out("\n"); } // Route a parsed (group, command) to its handler. `parse` only returns a @@ -187,19 +213,28 @@ main :: () -> ! { } // Command table + flag specs live in this scope; `Parsed` holds VIEWS - // into them, used before `main` returns. `ci publish` requires - // `--manifest` + `--local-store`; the global `--json` is recognized by - // the parser without being declared here. promote/rollback flags arrive - // with their real handlers in P3.5. - no_flags : []FlagSpec = .[]; + // into them, used before `main` returns. Every value flag below is + // required; the global `--json` is recognized by the parser without + // being declared here. publish_flags : []FlagSpec = .[ FlagSpec.{ name = "manifest", takes_value = true, required = true }, FlagSpec.{ name = "local-store", takes_value = true, required = true }, ]; + promote_flags : []FlagSpec = .[ + FlagSpec.{ name = "app", takes_value = true, required = true }, + FlagSpec.{ name = "channel", takes_value = true, required = true }, + FlagSpec.{ name = "release", takes_value = true, required = true }, + FlagSpec.{ name = "local-store", takes_value = true, required = true }, + ]; + rollback_flags : []FlagSpec = .[ + FlagSpec.{ name = "app", takes_value = true, required = true }, + FlagSpec.{ name = "channel", takes_value = true, required = true }, + FlagSpec.{ name = "local-store", takes_value = true, required = true }, + ]; cmds : []Command = .[ Command.{ group = "ci", command = "publish", flags = publish_flags }, - Command.{ group = "release", command = "promote", flags = no_flags }, - Command.{ group = "release", command = "rollback", flags = no_flags }, + Command.{ group = "release", command = "promote", flags = promote_flags }, + Command.{ group = "release", command = "rollback", flags = rollback_flags }, ]; diag : Diag = .{}; diff --git a/src/json_out.sx b/src/json_out.sx index c3bc2da..5ecd16a 100644 --- a/src/json_out.sx +++ b/src/json_out.sx @@ -35,17 +35,3 @@ write_error :: (f: CliFailure, dst: []u8) -> (s64, !JsonError) { n := try write_to_buffer(root, dst); return n; } - -// Serialize a stub command's machine result — `{ "command": , -// "status": "ok", "stub": true }` — into the caller-owned `dst`, returning -// the number of bytes written. Overflow surfaces on the error channel. -write_stub :: (cmd: string, dst: []u8) -> (s64, !JsonError) { - gpa := GPA.init(); - obj : Object = .{}; - obj.put("command", .str(cmd), xx gpa); - obj.put("status", .str("ok"), xx gpa); - obj.put("stub", .bool_(true), xx gpa); - root : Value = .object(obj); - n := try write_to_buffer(root, dst); - return n; -} diff --git a/src/release/ops.sx b/src/release/ops.sx new file mode 100644 index 0000000..7532e37 --- /dev/null +++ b/src/release/ops.sx @@ -0,0 +1,298 @@ +// ===================================================================== +// ops.sx — standalone channel operations over the persisted store +// (subplan 03 / P3.5): `dist release promote` and `dist release rollback`. +// +// Both load `/db.json`, mutate ONE channel pointer, append an audit +// event, and re-persist. They are the human counterpart to the CI publish: +// CI writes releases; a release manager moves channel pointers. +// +// PROMOTE points an (app, channel) at a given release id. The release must +// exist and belong to the app; it does NOT have to target that channel — +// promotion across channels (a beta release promoted onto stable) is the +// point of the command. A missing channel is created. The policy gate is +// the v0 stub: `.manual` always allows. +// +// ROLLBACK moves the channel pointer to the PREVIOUS valid release for +// that channel. "Previous" is publish order: among the app's PUBLISHED +// releases targeting this channel (`release.channel == name`, +// `published_at > 0` — rollback never selects an unpublished release), the +// one immediately before the current pointer. When the current pointer is +// not in that lineage (it was cross-promoted from another channel), +// rollback returns to the channel's own latest release. At the earliest +// release — or with no lineage at all — there is nothing to roll back to. +// +// FAILURE CONTRACT (mirrors P3.4b): every abort happens before `db.save`, +// so a failed operation never changes db.json. Each raise site first +// writes a `jout.CliFailure` (stable dotted code + human message). +// ===================================================================== + +#import "modules/std.sx"; +#import "modules/std/json.sx"; +#import "modules/std/fs.sx"; +#import "../domain/platform.sx"; +#import "../domain/app.sx"; +#import "../domain/release.sx"; +#import "../domain/artifact.sx"; +#import "../domain/channel.sx"; +#import "../domain/audit.sx"; +#import "../domain/validate.sx"; +#import "../repo/repo.sx"; +db :: #import "../repo/db.sx"; +jout :: #import "../json_out.sx"; +pl :: #import "../publish/publish.sx"; + +// Failure classes for a channel operation. The precise reason travels in +// the caller's `jout.CliFailure` (see the failure contract above). +// Load — db.json absent or unreadable (no publishable state). +// NotFound — the named app / release / channel does not exist. +// Invalid — the aggregate is inconsistent (release of another app, +// channel that fails domain validation, nothing to roll +// back to). +// Persist — db.json could not be re-written. +OpError :: error { + Load, + NotFound, + Invalid, + Persist, +} + +PromoteOutcome :: struct { + app_id: string; + channel: string; + release_id: string; + version: string; + previous_release_id: string; // "" when the channel was just created +} + +RollbackOutcome :: struct { + app_id: string; + channel: string; + from_release_id: string; + to_release_id: string; + to_version: string; +} + +// ── shared steps ────────────────────────────────────────────────────── + +// Load the persisted model, or fail with `store.load` when the store has +// no db.json (nothing was ever published there). +op_load_repo :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !OpError) { + if !exists(path_join(store_dir, "db.json")) { + fail_out.code = "store.load"; + fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir); + raise error.Load; + } + loaded, le := db.load(store_dir); + if le { + fail_out.code = "store.load"; + fail_out.message = concat("db.json under the store could not be loaded: ", store_dir); + raise error.Load; + } + return loaded; +} + +op_find_app :: (repo: *Repo, slug: string, code: string, fail_out: *jout.CliFailure) -> (App, !OpError) { + a := repo.find_app_by_slug(slug); + if a == null { + fail_out.code = code; + fail_out.message = concat("no app with that slug in the store: ", slug); + raise error.NotFound; + } + return a!; +} + +op_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !OpError { + werr := false; + db.save(repo, store_dir) catch { werr = true; }; + if werr { + fail_out.code = "persist.save"; + fail_out.message = concat("db.json could not be written under the store: ", store_dir); + raise error.Persist; + } + return; +} + +// ── promote ─────────────────────────────────────────────────────────── + +run_promote :: (store_dir: string, app_slug: string, channel_name: string, release_id: string, fail_out: *jout.CliFailure) -> (PromoteOutcome, !OpError) { + repo := try op_load_repo(store_dir, fail_out); + app := try op_find_app(@repo, app_slug, "promote.unknown_app", fail_out); + + relq := repo.get_release(release_id); + if relq == null { + fail_out.code = "promote.unknown_release"; + fail_out.message = concat("no release with that id in the store: ", release_id); + raise error.NotFound; + } + rel := relq!; + if rel.app_id != app.id { + fail_out.code = "promote.wrong_app"; + fail_out.message = concat("release belongs to a different app: ", release_id); + raise error.Invalid; + } + + // Policy gate (v0 stub): `.manual` always allows. A real gate (stable + // requires passed validations, percentage rollouts) lands with policies. + prev := ""; + chan : Channel = .{}; + cq := repo.get_channel(app.id, channel_name); + if cq == null { + chan = Channel.{ + app_id = app.id, name = channel_name, current_release_id = release_id, + policy = .manual, rollout_percent = 100, + }; + } else { + chan = cq!; + prev = chan.current_release_id; + chan.current_release_id = release_id; + } + cverr := false; + validate_channel(chan) catch { cverr = true; }; + if cverr { + fail_out.code = "promote.bad_channel"; + fail_out.message = concat("channel fails domain validation: ", channel_name); + raise error.Invalid; + } + if cq == null { repo.create_channel(chan); } + else { repo.update_channel(chan); } + + now := pl.now_secs(); + repo.create_audit_event(AuditEvent.{ + id = concat(concat(concat(concat("evt-cli-promote-", app.id), "-"), channel_name), concat("-", release_id)), + actor = "cli", action = "channel.promote", target_type = "channel", + target_id = channel_name, metadata = release_id, created_at = now, + }); + + try op_save(@repo, store_dir, fail_out); + + return PromoteOutcome.{ + app_id = app.id, channel = channel_name, + release_id = release_id, version = rel.version, + previous_release_id = prev, + }; +} + +// ── rollback ────────────────────────────────────────────────────────── + +run_rollback :: (store_dir: string, app_slug: string, channel_name: string, fail_out: *jout.CliFailure) -> (RollbackOutcome, !OpError) { + repo := try op_load_repo(store_dir, fail_out); + app := try op_find_app(@repo, app_slug, "rollback.unknown_app", fail_out); + + cq := repo.get_channel(app.id, channel_name); + if cq == null { + fail_out.code = "rollback.unknown_channel"; + fail_out.message = concat("the app has no channel with that name: ", channel_name); + raise error.NotFound; + } + chan := cq!; + from := chan.current_release_id; + + // The channel's lineage: the app's PUBLISHED releases targeting this + // channel, in publish (insertion) order. The target is the entry just + // before the current pointer — or the latest entry when the pointer + // was cross-promoted from another channel's lineage. + target_id := ""; + target_version := ""; + prev_id := ""; + prev_version := ""; + found_current := false; + i := 0; + while i < repo.releases.len { + r := repo.releases.items[i]; + if r.app_id == app.id and r.channel == channel_name and r.published_at > 0 { + if r.id == from { + found_current = true; + target_id = prev_id; + target_version = prev_version; + break; + } + prev_id = r.id; + prev_version = r.version; + } + i += 1; + } + if !found_current { + target_id = prev_id; // latest of the channel's own lineage + target_version = prev_version; + } + if target_id.len == 0 { + fail_out.code = "rollback.no_previous"; + fail_out.message = concat("no previous published release to roll back to on channel: ", channel_name); + raise error.Invalid; + } + + chan.current_release_id = target_id; + repo.update_channel(chan); + + now := pl.now_secs(); + repo.create_audit_event(AuditEvent.{ + id = concat(concat(concat(concat("evt-cli-rollback-", app.id), "-"), channel_name), concat("-", target_id)), + actor = "cli", action = "channel.rollback", target_type = "channel", + target_id = channel_name, metadata = target_id, created_at = now, + }); + + try op_save(@repo, store_dir, fail_out); + + return RollbackOutcome.{ + app_id = app.id, channel = channel_name, + from_release_id = from, + to_release_id = target_id, to_version = target_version, + }; +} + +// ── rendering ───────────────────────────────────────────────────────── + +// `{"status":"promoted","app_id":...,"channel":...,"release":{"id":..., +// "version":...},"previous_release_id":...}` — member order fixed by +// `std.json`'s insertion-order guarantee. +write_promote_json :: (o: *PromoteOutcome, dst: []u8) -> (s64, !JsonError) { + gpa := GPA.init(); + root : Object = .{}; + root.put("status", .str("promoted"), xx gpa); + root.put("app_id", .str(o.app_id), xx gpa); + root.put("channel", .str(o.channel), xx gpa); + rel : Object = .{}; + rel.put("id", .str(o.release_id), xx gpa); + rel.put("version", .str(o.version), xx gpa); + root.put("release", .object(rel), xx gpa); + root.put("previous_release_id", .str(o.previous_release_id), xx gpa); + rootv : Value = .object(root); + n := try write_to_buffer(rootv, dst); + return n; +} + +// `{"status":"rolled_back","app_id":...,"channel":...,"from_release_id":..., +// "to":{"id":...,"version":...}}`. +write_rollback_json :: (o: *RollbackOutcome, dst: []u8) -> (s64, !JsonError) { + gpa := GPA.init(); + root : Object = .{}; + root.put("status", .str("rolled_back"), xx gpa); + root.put("app_id", .str(o.app_id), xx gpa); + root.put("channel", .str(o.channel), xx gpa); + root.put("from_release_id", .str(o.from_release_id), xx gpa); + to : Object = .{}; + to.put("id", .str(o.to_release_id), xx gpa); + to.put("version", .str(o.to_version), xx gpa); + root.put("to", .object(to), xx gpa); + rootv : Value = .object(root); + n := try write_to_buffer(rootv, dst); + return n; +} + +promote_human :: (o: *PromoteOutcome) -> string { + s := concat("promoted ", o.release_id); + s = concat(s, concat(" (", concat(o.version, ")"))); + s = concat(s, concat(" onto channel ", o.channel)); + if o.previous_release_id.len > 0 { + s = concat(s, concat(" (was ", concat(o.previous_release_id, ")"))); + } + return concat(s, "\n"); +} + +rollback_human :: (o: *RollbackOutcome) -> string { + s := concat("rolled back channel ", o.channel); + s = concat(s, concat(": ", o.from_release_id)); + s = concat(s, concat(" -> ", o.to_release_id)); + s = concat(s, concat(" (", concat(o.to_version, ")"))); + return concat(s, "\n"); +} diff --git a/tests/cli_dispatch.sx b/tests/cli_dispatch.sx index 427bbc1..950c07c 100644 --- a/tests/cli_dispatch.sx +++ b/tests/cli_dispatch.sx @@ -8,13 +8,15 @@ // // 1. no args → human help/usage on STDERR + EX_USAGE (64). // 2. unknown command → human error on STDERR + EX_USAGE (64). -// 3. `release promote --json` → STDOUT is a SINGLE valid JSON object -// (parses via std.json with no trailing junk); the human acknowledgement -// is on STDERR, never stdout. (`release promote` is still a stub; the -// real `ci publish` json output is exercised by publish_happy.sx.) +// 3. a fully-flagged `release promote --json` against a store that was +// never published → exit 1 (command failed, NOT usage), and STDOUT is +// a SINGLE valid JSON error object (parses via std.json with no +// trailing junk; status "error", code "store.load"); the human +// sentence is on STDERR, never stdout. (Success-path json output is +// exercised by publish_happy.sx / release_ops.sx.) // 4. `--help` → lists the `ci` / `release` groups, exits 0. -// 5. `ci publish --json` with NO required flags → EX_USAGE (64), error on -// stderr (the --manifest / --local-store contract). +// 5. `ci publish --json` / `release promote --json` with NO required +// flags → EX_USAGE (64), error on stderr (the required-flag contract). // // `make test` depends on `build`, so `build/dist` exists before this runs; // the relative path resolves from the repo root (the `make test` cwd). @@ -65,27 +67,31 @@ main :: () -> s32 { proc.assert(false, "spawn build/dist bogus failed"); } - // ── 3a. `--json` stdout purity: a single valid JSON object, nothing - // else. `2>/dev/null` drops the human note so the pipe carries - // ONLY stdout; std.json.parse rejects trailing junk. ───────── - if r := proc.run("build/dist release promote --json 2>/dev/null") { - proc.assert(r.exit_code == 0, "stub --json command must succeed (EX_OK)"); + // ── 3a. `--json` stdout purity on a FAILURE path: a single valid + // JSON error object, nothing else. The store dir was never + // published into, so the command fails with `store.load` and + // exit 1 (command failed — distinct from usage's 64). + // `2>/dev/null` drops the human note so the pipe carries ONLY + // stdout; std.json.parse rejects trailing junk. ────────────── + PROMOTE :: "build/dist release promote --app x --channel beta --release rel-x --local-store .sx-tmp/cli_dispatch_nostore --json"; + if r := proc.run(concat(PROMOTE, " 2>/dev/null")) { + proc.assert(r.exit_code == 1, "failed command must exit 1 (not EX_USAGE)"); v, e := json.parse(r.stdout, xx gpa); proc.assert(!e, "stdout in --json mode must be a single valid JSON object (parse failed / trailing junk)"); if !e { o := v.object; - proc.assert(o.len == 3, "stub json object carries command/status/stub"); - proc.assert(o.items[0].key == "command" and o.items[0].val.str == "release promote", - "stub json names the dispatched command"); - proc.assert(o.items[1].key == "status" and o.items[1].val.str == "ok", - "stub json reports status ok"); + proc.assert(o.items[0].key == "status" and o.items[0].val.str == "error", + "failure json reports status error"); + eo := o.items[1].val.object; + proc.assert(eo.items[0].key == "code" and eo.items[0].val.str == "store.load", + "failure json names the store.load code"); } } else { proc.assert(false, "spawn build/dist release promote --json failed"); } // ── 3b. `--json` mode keeps human text on STDERR (not stdout) ────── - if r := proc.run("build/dist release promote --json 2>&1 1>/dev/null") { + if r := proc.run(concat(PROMOTE, " 2>&1 1>/dev/null")) { proc.assert(r.stdout.len > 0, "--json mode must still emit human text to stderr"); } else { proc.assert(false, "spawn build/dist release promote --json (stderr) failed"); @@ -100,7 +106,7 @@ main :: () -> s32 { proc.assert(false, "spawn build/dist --help failed"); } - // ── 5. `ci publish` requires --manifest / --local-store ─────────── + // ── 5. required flags: ci publish AND release promote ───────────── // Missing a required flag is a usage error: EX_USAGE (64), human // diagnostic on stderr (`2>&1 1>/dev/null` captures the stderr text). if r := proc.run("build/dist ci publish --json 2>&1 1>/dev/null") { @@ -109,6 +115,12 @@ main :: () -> s32 { } else { proc.assert(false, "spawn build/dist ci publish (no flags) failed"); } + if r := proc.run("build/dist release promote --json 2>&1 1>/dev/null") { + proc.assert(r.exit_code == 64, "release promote without required flags must exit EX_USAGE (64)"); + proc.assert(contains(r.stdout, "missing required flag"), "missing-flag error names the failure on stderr"); + } else { + proc.assert(false, "spawn build/dist release promote (no flags) failed"); + } print("cli_dispatch: ok\n"); return 0; diff --git a/tests/release_ops.sx b/tests/release_ops.sx new file mode 100644 index 0000000..cc14a5a --- /dev/null +++ b/tests/release_ops.sx @@ -0,0 +1,182 @@ +// Pinned acceptance for P3.5 — `dist release promote` / `dist release +// rollback` over the persisted store. +// +// Drives the BUILT `build/dist` binary (via `process.run`, like +// publish_persist.sx) through the slice plan's scripted scenario: +// +// 1. Publish release A (1.2.3, channel beta) then B (1.2.4, beta) into +// one store → beta points at B. +// 2. `release rollback --app --channel beta` → exit 0, JSON +// `rolled_back` from B to A; db.json's beta points at A; a +// `channel.rollback` audit event (actor "cli") is recorded. +// 3. `release promote --release ` → exit 0, JSON `promoted` with +// previous_release_id A; beta points at B again; a `channel.promote` +// audit event by actor "cli" is recorded (distinct from the publish +// pipeline's "ci" promote events). +// 4. Promoting an UNKNOWN release id → exit 1 + JSON error +// (`promote.unknown_release`); db.json unchanged (beta still → B). +// 5. Rollback again (B → A), then rollback at the EARLIEST release → +// exit 1 + `rollback.no_previous`; beta still → A. A failed op never +// moves the pointer. +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/std/process.sx"; +fs :: #import "modules/std/fs.sx"; + +STORE :: ".sx-tmp/release_ops"; +MDIR :: ".sx-tmp/release_ops_m"; + +MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; + +REL_A :: "rel-acme-app-1.2.3"; +REL_B :: "rel-acme-app-1.2.4"; + +get :: (o: Object, key: string) -> Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + process.assert(false, concat("missing json key: ", key)); + dummy : Value = .null_; + return dummy; +} +get_str :: (o: Object, key: string) -> string { return get(o, key).str; } +get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } +get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; } + +// Count audit events matching (actor, action) — distinguishes the CLI's +// channel events from the publish pipeline's "ci" ones. +count_actor_action :: (events: Array, actor: string, action: string) -> s64 { + c : s64 = 0; + i := 0; + while i < events.len { + eo := events.items[i].object; + if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; } + i += 1; + } + return c; +} + +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +load_db :: (scratch: Allocator) -> Object { + db_bytes := fs.read_file(path_join(STORE, "db.json")); + process.assert(db_bytes != null, "db.json must exist under the store"); + dv, de := parse(db_bytes!, scratch); + if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; } + return dv.object; +} + +// The beta channel's current_release_id, read fresh from db.json. +beta_pointer :: (scratch: Allocator) -> string { + dbo := load_db(scratch); + chans := get_arr(dbo, "channels"); + process.assert(chans.len == 1, "store has exactly one channel"); + return get_str(chans.items[0].object, "current_release_id"); +} + +publish_cmd :: (mpath: string) -> string { + c := concat("build/dist ci publish --manifest ", mpath); + c = concat(c, concat(" --local-store ", STORE)); + return concat(c, " --json 2>/dev/null"); +} + +promote_cmd :: (release_id: string) -> string { + c := concat("build/dist release promote --app acme-app --channel beta --release ", release_id); + c = concat(c, concat(" --local-store ", STORE)); + return concat(c, " --json 2>/dev/null"); +} + +ROLLBACK_CMD :: "build/dist release rollback --app acme-app --channel beta --local-store .sx-tmp/release_ops --json 2>/dev/null"; + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + process.run(concat("mkdir -p ", MDIR)); + write_file(path_join(MDIR, "a.json"), MANIFEST_A); + write_file(path_join(MDIR, "b.json"), MANIFEST_B); + + // ── 1. Publish A then B → beta points at B ────────────────────── + ra := process.run(publish_cmd(path_join(MDIR, "a.json"))); + process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0"); + rb := process.run(publish_cmd(path_join(MDIR, "b.json"))); + process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0"); + process.assert(beta_pointer(xx arena) == REL_B, "after publishes: beta -> B"); + print(" published A then B; beta -> B\n"); + + // ── 2. Rollback: beta moves B -> A, audited ────────────────────── + rr := process.run(ROLLBACK_CMD); + process.assert(rr != null, "spawn rollback failed"); + process.assert(rr!.exit_code == 0, "rollback must exit 0"); + rv, re := parse(rr!.stdout, xx arena); + if re { process.assert(false, "rollback stdout must be one JSON object"); return 1; } + ro := rv.object; + process.assert(get_str(ro, "status") == "rolled_back", "rollback json status"); + process.assert(get_str(ro, "from_release_id") == REL_B, "rollback json from B"); + process.assert(get_str(get_obj(ro, "to"), "id") == REL_A, "rollback json to A"); + process.assert(beta_pointer(xx arena) == REL_A, "after rollback: beta -> A"); + db2 := load_db(xx arena); + process.assert(count_actor_action(get_arr(db2, "audit_events"), "cli", "channel.rollback") == 1, + "rollback recorded one cli channel.rollback audit event"); + print(" rollback: beta B -> A, audited\n"); + + // ── 3. Promote B back: beta -> B, previous is A, audited ───────── + rp := process.run(promote_cmd(REL_B)); + process.assert(rp != null, "spawn promote failed"); + process.assert(rp!.exit_code == 0, "promote must exit 0"); + pv, pe := parse(rp!.stdout, xx arena); + if pe { process.assert(false, "promote stdout must be one JSON object"); return 1; } + po := pv.object; + process.assert(get_str(po, "status") == "promoted", "promote json status"); + process.assert(get_str(get_obj(po, "release"), "id") == REL_B, "promote json release B"); + process.assert(get_str(po, "previous_release_id") == REL_A, "promote json previous A"); + process.assert(beta_pointer(xx arena) == REL_B, "after promote: beta -> B"); + db3 := load_db(xx arena); + process.assert(count_actor_action(get_arr(db3, "audit_events"), "cli", "channel.promote") == 1, + "promote recorded one cli channel.promote audit event"); + print(" promote: beta -> B (was A), audited\n"); + + // ── 4. Promote an unknown release id → exit 1 + JSON error, db + // unchanged ───────────────────────────────────────────────── + rn := process.run(promote_cmd("rel-nope")); + process.assert(rn != null, "spawn promote-unknown failed"); + process.assert(rn!.exit_code == 1, "promote of unknown release must exit 1"); + nv, ne := parse(rn!.stdout, xx arena); + if ne { process.assert(false, "promote-unknown stdout must be one JSON object"); return 1; } + no := nv.object; + process.assert(get_str(no, "status") == "error", "promote-unknown json status error"); + process.assert(get_str(get_obj(no, "error"), "code") == "promote.unknown_release", + "promote-unknown json names the code"); + process.assert(beta_pointer(xx arena) == REL_B, "after failed promote: beta unchanged (-> B)"); + print(" promote unknown release: exit 1 + JSON error, beta unchanged\n"); + + // ── 5. Rollback to the earliest, then once more → no_previous ──── + r2 := process.run(ROLLBACK_CMD); + process.assert(r2 != null and r2!.exit_code == 0, "second rollback must exit 0 (B -> A)"); + process.assert(beta_pointer(xx arena) == REL_A, "after second rollback: beta -> A"); + + r3 := process.run(ROLLBACK_CMD); + process.assert(r3 != null, "spawn third rollback failed"); + process.assert(r3!.exit_code == 1, "rollback at the earliest release must exit 1"); + v3, e3 := parse(r3!.stdout, xx arena); + if e3 { process.assert(false, "no-previous stdout must be one JSON object"); return 1; } + o3 := v3.object; + process.assert(get_str(get_obj(o3, "error"), "code") == "rollback.no_previous", + "no-previous json names the code"); + process.assert(beta_pointer(xx arena) == REL_A, "after failed rollback: beta unchanged (-> A)"); + print(" rollback at earliest: exit 1 + no_previous, beta unchanged\n"); + + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("release_ops: ALL CASES PASS\n"); + return 0; +}