From 0d503db8e7fe76db1e2d32b2c4f4bbd53b371509 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 08:06:15 -0400 Subject: [PATCH 1/5] feat: clipboard bridge Go layer -- protocol core, route node, seeded routes, preview/envelope bindings (goal 0099) domain/clipbridge: the OUT envelope (inline JSON Schema 2020-12, kind/action enums as the only injection points), ParseReply via the vendored jsonschema/v6 with named per-failure errors, and the effect- class taxonomy with the hard cap in code (replace/delete never auto-runs; the subset-guard test pins the schema vocabulary). composition: apply-atlas-from-reply (a thin batch node over an injected materializer) + two seeded routes the Quick Panel runs on accept, both in the seed-proof registry with end-to-end executionsvc proofs. atlassvc: PreviewClipbridgeReply (collision-annotated), CardContextEnvelope (the machine-readable twin of the plain-text context block), and the materializer (kind-label resolution, summary-field mapping, Scratchpad note landing). Contract document regenerated for the new node type; fingerprints extended for the new goldens (all SeedRevision 1). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- internal/contract/contract.json | 82 ++++++ internal/domain/atlas/builtin.go | 5 + internal/domain/clipbridge/class.go | 59 +++++ internal/domain/clipbridge/clipbridge_test.go | 191 ++++++++++++++ internal/domain/clipbridge/envelope.go | 84 +++++++ internal/domain/clipbridge/schema.go | 70 ++++++ internal/domain/clipbridge/validate.go | 182 ++++++++++++++ internal/domain/composition/atlasfromreply.go | 86 +++++++ .../domain/composition/atlasfromreply_test.go | 60 +++++ .../domain/composition/builtinworkflows.go | 5 +- .../builtinworkflows_clipbridge.go | 75 ++++++ internal/domain/composition/seedproof_test.go | 7 + .../atlassvc/atlasservice_clipbridge.go | 228 +++++++++++++++++ .../atlassvc/atlasservice_clipbridge_test.go | 156 ++++++++++++ .../atlassvc/atlasservice_composition.go | 1 + .../executionsvc/clipbridge_seed_test.go | 142 +++++++++++ .../services/seeding/seed_fingerprints.json | 236 +++++++++--------- 17 files changed, 1554 insertions(+), 115 deletions(-) create mode 100644 internal/domain/clipbridge/class.go create mode 100644 internal/domain/clipbridge/clipbridge_test.go create mode 100644 internal/domain/clipbridge/envelope.go create mode 100644 internal/domain/clipbridge/schema.go create mode 100644 internal/domain/clipbridge/validate.go create mode 100644 internal/domain/composition/atlasfromreply.go create mode 100644 internal/domain/composition/atlasfromreply_test.go create mode 100644 internal/domain/composition/builtinworkflows_clipbridge.go create mode 100644 internal/services/atlassvc/atlasservice_clipbridge.go create mode 100644 internal/services/atlassvc/atlasservice_clipbridge_test.go create mode 100644 internal/services/executionsvc/clipbridge_seed_test.go diff --git a/internal/contract/contract.json b/internal/contract/contract.json index 5d0fec43..9865932f 100644 --- a/internal/contract/contract.json +++ b/internal/contract/contract.json @@ -2551,6 +2551,47 @@ "PaletteGroup": "", "Complexity": "advanced" }, + { + "ID": "apply-atlas-from-reply", + "Kind": "apply", + "Label": "Atlas: create from reply items", + "Description": "Creates Atlas records from an accepted clipboard reply's items: an item with a \"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. \"Items\" binds the attribute holding the accepted items as a JSON array.", + "ConfigFields": [ + { + "Key": "itemsAttribute", + "Label": "Items attribute", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field carries the accepted reply items (a JSON array).", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + }, + { + "Key": "outputAttribute", + "Label": "Output attribute (optional)", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field receives a summary of what was created.", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + } + ], + "Output": "payload unchanged; a created-summary JSON -\u003e the output attribute, if named", + "Effect": "local", + "Declared": false, + "PaletteGroup": "", + "Complexity": "advanced" + }, { "ID": "apply-backup-snapshot", "Kind": "apply", @@ -4196,6 +4237,47 @@ "PaletteGroup": "", "Complexity": "advanced" }, + { + "ID": "apply-atlas-from-reply", + "Kind": "apply", + "Label": "Atlas: create from reply items", + "Description": "Creates Atlas records from an accepted clipboard reply's items: an item with a \"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. \"Items\" binds the attribute holding the accepted items as a JSON array.", + "ConfigFields": [ + { + "Key": "itemsAttribute", + "Label": "Items attribute", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field carries the accepted reply items (a JSON array).", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + }, + { + "Key": "outputAttribute", + "Label": "Output attribute (optional)", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field receives a summary of what was created.", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + } + ], + "Output": "payload unchanged; a created-summary JSON -\u003e the output attribute, if named", + "Effect": "local", + "Declared": false, + "PaletteGroup": "", + "Complexity": "advanced" + }, { "ID": "apply-backup-snapshot", "Kind": "apply", diff --git a/internal/domain/atlas/builtin.go b/internal/domain/atlas/builtin.go index a023790d..584b04e1 100644 --- a/internal/domain/atlas/builtin.go +++ b/internal/domain/atlas/builtin.go @@ -50,6 +50,11 @@ const ( cardDocumentID = "atlas-card-example-document" cardScratchpadID = "atlas-card-scratchpad" + // BuiltInScratchpadCardID is the seeded Scratchpad inbox area -- + // exported because the clipboard bridge's note route (goal 0099) + // lands notes there from the service layer. + BuiltInScratchpadCardID = cardScratchpadID + linkGettingToContactID = "atlas-link-getting-to-contact" linkContactToDocumentID = "atlas-link-contact-to-document" ) diff --git a/internal/domain/clipbridge/class.go b/internal/domain/clipbridge/class.go new file mode 100644 index 00000000..068b5386 --- /dev/null +++ b/internal/domain/clipbridge/class.go @@ -0,0 +1,59 @@ +package clipbridge + +// ActionClass is the guardrail effect class an envelope action maps +// onto -- the taxonomy carrier is the existing gate's vocabulary, never +// a parallel system. +type ActionClass string + +const ( + ClassRead ActionClass = "read" + ClassCreate ActionClass = "create" + ClassReplace ActionClass = "replace" + ClassDelete ActionClass = "delete" + ClassUnknown ActionClass = "unknown" +) + +// The v1 actions, both create-class. Replace/delete-class actions do +// not exist yet; when one lands, the field-level before/after review +// UI is REQUIRED in the same change (recorded in the goal contract). +const ( + ActionCreateCards = "create-cards" + ActionNoteToScratchpad = "note-to-scratchpad" +) + +// V1Actions lists what the OUT envelope advertises today. +func V1Actions() []string { + return []string{ActionCreateCards, ActionNoteToScratchpad} +} + +// ClassOf maps an action to its effect class. Unknown actions classify +// as ClassUnknown and are refused upstream -- never guessed into a +// weaker class. +func ClassOf(action string) ActionClass { + switch action { + case ActionCreateCards, ActionNoteToScratchpad: + return ClassCreate + default: + return ClassUnknown + } +} + +// MayAutoRun reports whether a class may execute without a human +// review-accept. The hard cap: mutation of existing data is never +// agentic regardless of source -- replace and delete always return +// false here, and no guardrail rule may override this (it is enforced +// in code, not configuration, by design). +func MayAutoRun(c ActionClass) bool { + return c == ClassRead +} + +// RequiresReview reports whether a class must pass the review-accept +// surface before executing. Everything that writes does. +func RequiresReview(c ActionClass) bool { + switch c { + case ClassCreate, ClassReplace, ClassDelete: + return true + default: + return c == ClassUnknown + } +} diff --git a/internal/domain/clipbridge/clipbridge_test.go b/internal/domain/clipbridge/clipbridge_test.go new file mode 100644 index 00000000..686960fa --- /dev/null +++ b/internal/domain/clipbridge/clipbridge_test.go @@ -0,0 +1,191 @@ +package clipbridge + +import ( + "encoding/json" + "strings" + "testing" +) + +var testKinds = []string{"Topic", "Contact", "Document", "Component"} + +func validReply(t *testing.T, action string, items string) string { + t.Helper() + return `{"mill":1,"kind":"reply","action":"` + action + `","items":` + items + `}` +} + +func TestBuildContextEnvelope(t *testing.T) { + env, err := BuildContextEnvelope([]ContextCard{{Title: "Web app", Kind: "Component"}}, testKinds, V1Actions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env.Mill != EnvelopeVersion || env.Kind != KindContext { + t.Errorf("marker fields wrong: %+v", env) + } + if len(env.Items) != 1 || len(env.AllowedActions) != 2 { + t.Errorf("items/actions wrong: %+v", env) + } + if env.Instructions == "" { + t.Error("the one-line reply instruction is missing") + } + // The whole envelope must round-trip as one JSON object. + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + var back map[string]any + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("envelope is not one valid JSON object: %v", err) + } + for _, key := range []string{"mill", "kind", "instructions", "schema", "allowedActions", "items"} { + if _, ok := back[key]; !ok { + t.Errorf("envelope lost key %q", key) + } + } +} + +// The portable-subset guard: the reply schema's keyword vocabulary must +// stay inside the locked structured-output intersection. A keyword +// outside the allowlist appearing anywhere in the schema fails this +// test -- widening the vocabulary is a deliberate contract change, not +// a drive-by. +func TestReplySchema_KeywordsStayInsidePortableSubset(t *testing.T) { + allowed := map[string]bool{ + "$schema": true, "$defs": true, "$ref": true, + "type": true, "properties": true, "required": true, + "additionalProperties": true, "enum": true, "items": true, + } + raw, err := ReplySchema(testKinds, V1Actions()) + if err != nil { + t.Fatalf("ReplySchema: %v", err) + } + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + var walk func(v any, inProperties bool) + walk = func(v any, inProperties bool) { + switch node := v.(type) { + case map[string]any: + for k, child := range node { + if !inProperties && !allowed[k] { + t.Errorf("schema keyword %q is outside the portable subset", k) + } + // Values under "properties"/"$defs" are NAMES, not + // keywords; their children are schemas again. + walk(child, k == "properties" || k == "$defs") + } + case []any: + for _, child := range node { + walk(child, false) + } + } + } + walk(doc, false) +} + +func TestParseReply_CreateCards(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"CRM","kind":"Component","note":"vendor system"}]`), testKinds, V1Actions()) + if !p.Recognized || !p.Valid { + t.Fatalf("expected a valid reply, got %+v", p) + } + if p.Class != ClassCreate || len(p.Cards) != 1 || p.Cards[0].Title != "CRM" { + t.Errorf("parse result wrong: %+v", p) + } +} + +func TestParseReply_FencedJSONIsTolerated(t *testing.T) { + fenced := "```json\n" + validReply(t, ActionCreateCards, `[{"title":"CRM"}]`) + "\n```" + p := ParseReply(fenced, testKinds, V1Actions()) + if !p.Valid { + t.Fatalf("fenced reply should validate, got %+v", p) + } +} + +func TestParseReply_RefusalsNameThemselves(t *testing.T) { + t.Run("not an envelope at all", func(t *testing.T) { + p := ParseReply("just some prose", testKinds, V1Actions()) + if p.Recognized { + t.Fatalf("prose must not be recognized: %+v", p) + } + }) + t.Run("wrong version", func(t *testing.T) { + p := ParseReply(`{"mill":2,"kind":"reply","action":"create-cards","items":[]}`, testKinds, V1Actions()) + if !p.Recognized || p.Valid || len(p.Errors) == 0 || !strings.Contains(p.Errors[0], "version") { + t.Fatalf("expected a version refusal, got %+v", p) + } + }) + t.Run("unknown action refused by the schema enum", func(t *testing.T) { + p := ParseReply(validReply(t, "delete-everything", `[{"title":"x"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("unknown action must not validate: %+v", p) + } + if p.Class != ClassUnknown { + t.Errorf("unknown action must classify unknown, got %q", p.Class) + } + }) + t.Run("card without title", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"note":"no title"}]`), testKinds, V1Actions()) + if p.Valid || len(p.Errors) == 0 || !strings.Contains(p.Errors[0], "title") { + t.Fatalf("expected a named title refusal, got %+v", p) + } + }) + t.Run("stray item field refused by additionalProperties", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"x","payload":"smuggled"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("stray fields must not validate: %+v", p) + } + }) + t.Run("unknown kind refused by the kind enum", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"x","kind":"Missile"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("unknown kind must not validate: %+v", p) + } + }) + t.Run("empty items", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[]`), testKinds, V1Actions()) + if p.Valid || len(p.Errors) == 0 { + t.Fatalf("empty items must be refused with a named error, got %+v", p) + } + }) +} + +func TestParseReply_NoteToScratchpad(t *testing.T) { + p := ParseReply(validReply(t, ActionNoteToScratchpad, `[{"text":"remember this"}]`), testKinds, V1Actions()) + if !p.Valid || len(p.NoteTexts) != 1 || p.NoteTexts[0] != "remember this" { + t.Fatalf("expected one note text, got %+v", p) + } +} + +// The hard cap in code: replace/delete classes may never auto-run, and +// everything that writes requires review. +func TestTaxonomyHardCap(t *testing.T) { + if MayAutoRun(ClassReplace) || MayAutoRun(ClassDelete) || MayAutoRun(ClassCreate) || MayAutoRun(ClassUnknown) { + t.Fatal("only read-class may auto-run") + } + if !MayAutoRun(ClassRead) { + t.Fatal("read-class is allowed agentically") + } + for _, c := range []ActionClass{ClassCreate, ClassReplace, ClassDelete, ClassUnknown} { + if !RequiresReview(c) { + t.Fatalf("class %q must require review", c) + } + } +} + +// A round-trip through the emitted schema: a reply an AI would produce +// from the envelope validates; the envelope itself does NOT validate as +// a reply (the two shapes stay distinguishable). +func TestEnvelopeAndReplyStayDistinguishable(t *testing.T) { + env, err := BuildContextEnvelope([]ContextCard{{Title: "A", Kind: "Topic"}}, testKinds, V1Actions()) + if err != nil { + t.Fatalf("build: %v", err) + } + raw, _ := json.Marshal(env) + p := ParseReply(string(raw), testKinds, V1Actions()) + if !p.Recognized { + t.Fatal("an envelope still carries the mill marker") + } + if p.Valid { + t.Fatal("a context envelope must not validate as a reply") + } +} diff --git a/internal/domain/clipbridge/envelope.go b/internal/domain/clipbridge/envelope.go new file mode 100644 index 00000000..e9d25d78 --- /dev/null +++ b/internal/domain/clipbridge/envelope.go @@ -0,0 +1,84 @@ +// Package clipbridge is the clipboard bridge's protocol core (goal +// 0099): the JSON envelope Mill copies OUT for an external AI, and the +// validated reply it accepts back IN. The clipboard is the transport, +// the inline JSON Schema is the protocol, and the guardrail taxonomy +// (class.go) decides what a reply may do. Pure domain: no clipboard +// access, no persistence -- the service layer owns those. +package clipbridge + +import ( + "encoding/json" + "fmt" + "sort" +) + +// EnvelopeVersion is the "mill" marker value; a reply carrying a +// different version is refused with an honest error, never guessed at. +const EnvelopeVersion = 1 + +// KindContext is the OUT envelope's kind: card data plus the reply +// contract an external AI answers against. +const KindContext = "context" + +// Envelope is the single JSON object Copy as context places on the +// clipboard. Schema is an INLINE JSON Schema 2020-12 document (the +// transport is an offline blob -- no URI resolution), restricted to the +// portable structured-output subset replySchemaBase documents. +type Envelope struct { + Mill int `json:"mill"` + Kind string `json:"kind"` + Instructions string `json:"instructions"` + Schema json.RawMessage `json:"schema"` + AllowedActions []string `json:"allowedActions"` + Items []json.RawMessage `json:"items"` +} + +// ContextCard is one card's structured data inside an OUT envelope -- +// the same facts renderCardContext prints as prose, as data. +type ContextCard struct { + Title string `json:"title"` + Kind string `json:"kind"` + Note string `json:"note,omitempty"` + Fields map[string]string `json:"fields,omitempty"` + Links []ContextLink `json:"links,omitempty"` +} + +// ContextLink names a relationship from the card's own point of view. +type ContextLink struct { + Kind string `json:"kind"` + Direction string `json:"direction"` // "out" or "in" + Title string `json:"title"` +} + +// replyInstructions is the one line of prose the contract allows; the +// schema is the rest of the instruction. +const replyInstructions = "Reply with a single JSON code block that conforms to the schema in this envelope's \"schema\" field." + +// BuildContextEnvelope assembles the OUT envelope for a set of cards. +// kindLabels and actions become enum values inside the reply schema -- +// the only dynamic parts, injected into fixed literal JSON so the +// portable-subset restriction can never widen at runtime. +func BuildContextEnvelope(cards []ContextCard, kindLabels []string, actions []string) (Envelope, error) { + schema, err := ReplySchema(kindLabels, actions) + if err != nil { + return Envelope{}, err + } + items := make([]json.RawMessage, 0, len(cards)) + for _, c := range cards { + raw, err := json.Marshal(c) + if err != nil { + return Envelope{}, fmt.Errorf("marshal context card %q: %w", c.Title, err) + } + items = append(items, raw) + } + sorted := append([]string(nil), actions...) + sort.Strings(sorted) + return Envelope{ + Mill: EnvelopeVersion, + Kind: KindContext, + Instructions: replyInstructions, + Schema: schema, + AllowedActions: sorted, + Items: items, + }, nil +} diff --git a/internal/domain/clipbridge/schema.go b/internal/domain/clipbridge/schema.go new file mode 100644 index 00000000..100a8401 --- /dev/null +++ b/internal/domain/clipbridge/schema.go @@ -0,0 +1,70 @@ +package clipbridge + +import ( + "encoding/json" + "fmt" +) + +// KindReply is the reply envelope's kind -- fixed, so a reply and a +// context envelope are distinguishable on the same clipboard. +const KindReply = "reply" + +// replySchemaTemplate is the committed literal reply contract. Two +// injection slots only -- the kind-label enum and the action enum -- +// both filled with json.Marshal-ed string arrays, so the keyword +// vocabulary can never widen at runtime. The vocabulary deliberately +// stays inside the portable structured-output intersection the goal +// contract locks: type/object/properties/required/ +// additionalProperties:false/enum/array items/local $refs -- no +// allOf/anyOf/if-then-else/external refs/recursion/format extensions +// (TestReplySchema_KeywordsStayInsidePortableSubset pins this). +// Per-action item requirements (a card needs title, a note needs text) +// are deliberately NOT expressed conditionally -- conditionals are +// outside the subset -- and are enforced by ParseReply instead. +const replySchemaTemplate = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["mill", "kind", "action", "items"], + "properties": { + "mill": {"type": "integer", "enum": [1]}, + "kind": {"type": "string", "enum": ["reply"]}, + "action": {"type": "string", "enum": %s}, + "items": {"type": "array", "items": {"$ref": "#/$defs/item"}} + }, + "$defs": { + "item": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"type": "string"}, + "kind": {"type": "string", "enum": %s}, + "note": {"type": "string"}, + "summary": {"type": "string"}, + "text": {"type": "string"} + } + } + } +}` + +// ReplySchema renders the reply contract with the current kind labels +// and allowed actions as enums. +func ReplySchema(kindLabels []string, actions []string) (json.RawMessage, error) { + if len(kindLabels) == 0 || len(actions) == 0 { + return nil, fmt.Errorf("reply schema needs at least one kind label and one action") + } + actionsJSON, err := json.Marshal(actions) + if err != nil { + return nil, fmt.Errorf("marshal actions enum: %w", err) + } + kindsJSON, err := json.Marshal(kindLabels) + if err != nil { + return nil, fmt.Errorf("marshal kinds enum: %w", err) + } + doc := fmt.Sprintf(replySchemaTemplate, actionsJSON, kindsJSON) + var compact json.RawMessage + if err := json.Unmarshal([]byte(doc), &compact); err != nil { + return nil, fmt.Errorf("rendered reply schema is not valid JSON: %w", err) + } + return json.RawMessage(doc), nil +} diff --git a/internal/domain/clipbridge/validate.go b/internal/domain/clipbridge/validate.go new file mode 100644 index 00000000..431864cb --- /dev/null +++ b/internal/domain/clipbridge/validate.go @@ -0,0 +1,182 @@ +package clipbridge + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +// CardDraft is one to-be-created card parsed from a valid create-cards +// reply -- exactly what the review surface previews. +type CardDraft struct { + Title string `json:"title"` + Kind string `json:"kind,omitempty"` + Note string `json:"note,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ReplyPreview is ParseReply's typed result. Never a bare Go error for +// user-visible failure modes -- the malformed cases render inline (the +// PreviewClipboardApply precedent), so every refusal names itself. +type ReplyPreview struct { + Recognized bool `json:"Recognized"` // a {"mill": ...} object at all + Valid bool `json:"Valid"` + Action string `json:"Action"` + Class ActionClass `json:"Class"` + Errors []string `json:"Errors"` + Cards []CardDraft `json:"Cards"` + NoteTexts []string `json:"NoteTexts"` +} + +type replyWire struct { + Mill *int `json:"mill"` + Kind string `json:"kind"` + Action string `json:"action"` + Items []json.RawMessage `json:"items"` +} + +// ParseReply validates a raw clipboard string against the v1 reply +// contract rendered with the CURRENT kind labels and actions. The +// schema validates shape; the per-action item requirements the schema +// deliberately cannot express (conditionals are outside the portable +// subset) are enforced here with named errors. +func ParseReply(raw string, kindLabels []string, actions []string) ReplyPreview { + trimmed := stripCodeFence(strings.TrimSpace(raw)) + var wire replyWire + if err := json.Unmarshal([]byte(trimmed), &wire); err != nil || wire.Mill == nil { + return ReplyPreview{Recognized: false} + } + p := ReplyPreview{Recognized: true, Action: wire.Action, Class: ClassOf(wire.Action)} + if *wire.Mill != EnvelopeVersion { + p.Errors = append(p.Errors, fmt.Sprintf("unsupported envelope version %d (this Mill speaks version %d)", *wire.Mill, EnvelopeVersion)) + return p + } + + schemaDocRaw, err := ReplySchema(kindLabels, actions) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema unavailable: %v", err)) + return p + } + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaDocRaw)) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema unreadable: %v", err)) + return p + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("mill://schema/clipbridge-reply/v1", schemaDoc); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema rejected: %v", err)) + return p + } + schema, err := compiler.Compile("mill://schema/clipbridge-reply/v1") + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema does not compile: %v", err)) + return p + } + instance, err := jsonschema.UnmarshalJSON(strings.NewReader(trimmed)) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("reply is not valid JSON: %v", err)) + return p + } + if err := schema.Validate(instance); err != nil { + p.Errors = append(p.Errors, schemaErrorLines(err)...) + return p + } + + // Shape is valid; now the per-action requirements. + switch wire.Action { + case ActionCreateCards: + for i, item := range wire.Items { + var d CardDraft + if err := json.Unmarshal(item, &d); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: unreadable: %v", i+1, err)) + continue + } + if strings.TrimSpace(d.Title) == "" { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: a card needs a non-empty \"title\"", i+1)) + continue + } + p.Cards = append(p.Cards, d) + } + if len(wire.Items) == 0 { + p.Errors = append(p.Errors, "the reply carries no items to create") + } + case ActionNoteToScratchpad: + for i, item := range wire.Items { + var d struct { + Text string `json:"text"` + } + if err := json.Unmarshal(item, &d); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: unreadable: %v", i+1, err)) + continue + } + if strings.TrimSpace(d.Text) == "" { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: a note needs a non-empty \"text\"", i+1)) + continue + } + p.NoteTexts = append(p.NoteTexts, d.Text) + } + if len(wire.Items) == 0 { + p.Errors = append(p.Errors, "the reply carries no items to save") + } + default: + // The schema's action enum already refused unknown actions; this + // branch only exists for defense in depth. + p.Errors = append(p.Errors, fmt.Sprintf("action %q is not offered by this Mill", wire.Action)) + } + + p.Valid = len(p.Errors) == 0 + return p +} + +// stripCodeFence tolerates a reply pasted from a chat surface that +// wraps JSON in a markdown code fence -- the contract asks for a bare +// JSON block, but a fenced one is unambiguous and refusing it would be +// pedantry the user pays for. +func stripCodeFence(s string) string { + if !strings.HasPrefix(s, "```") { + return s + } + body, ok := strings.CutPrefix(s, "```") + if !ok { + return s + } + if idx := strings.Index(body, "\n"); idx >= 0 { + body = body[idx+1:] + } + body = strings.TrimSpace(body) + body = strings.TrimSuffix(body, "```") + return strings.TrimSpace(body) +} + +// schemaErrorLines flattens a jsonschema validation error into honest, +// per-failure lines naming what failed where. +func schemaErrorLines(err error) []string { + var ve *jsonschema.ValidationError + if !errors.As(err, &ve) { + return []string{err.Error()} + } + printer := message.NewPrinter(language.English) + var lines []string + var walk func(e *jsonschema.ValidationError) + walk = func(e *jsonschema.ValidationError) { + if len(e.Causes) == 0 { + loc := "/" + strings.Join(e.InstanceLocation, "/") + lines = append(lines, fmt.Sprintf("%s: %s", loc, e.ErrorKind.LocalizedString(printer))) + return + } + for _, c := range e.Causes { + walk(c) + } + } + walk(ve) + if len(lines) == 0 { + lines = []string{err.Error()} + } + return lines +} diff --git a/internal/domain/composition/atlasfromreply.go b/internal/domain/composition/atlasfromreply.go new file mode 100644 index 00000000..3c4d0193 --- /dev/null +++ b/internal/domain/composition/atlasfromreply.go @@ -0,0 +1,86 @@ +package composition + +import ( + "encoding/json" + "fmt" + + "github.com/alicoding/mill/internal/domain/guardrail" +) + +// atlasReplyMaterializerFn turns a VALIDATED, user-accepted clipboard +// bridge reply's items (goal 0099 -- JSON array of drafts, each either +// a card draft with "title" or a note draft with "text") into real +// Atlas records -- injected so this domain package never depends on +// atlassvc's storage (.claude/rules/backend.md), which owns kind-label +// resolution, field mapping, and the Scratchpad landing for notes. +// Returns a short JSON summary ({"cards":N,"notes":N,"ids":[...]}). +// Defaults to erroring so a node run before SetAtlasReplyMaterializer +// is wired fails loudly. +var atlasReplyMaterializerFn = func(itemsJSON string, sourceRunID string) (string, error) { + return "", fmt.Errorf("no atlas reply materializer registered (yet)") +} + +// SetAtlasReplyMaterializer wires the function apply-atlas-from-reply +// nodes use. Called once from main.go once AtlasService exists. +func SetAtlasReplyMaterializer(fn func(itemsJSON string, sourceRunID string) (string, error)) { + atlasReplyMaterializerFn = fn +} + +func init() { + RegisterNodeType(NodeType{ + ID: "apply-atlas-from-reply", Kind: KindApply, + Label: "Atlas: create from reply items", + // ClassLocal: writes to Atlas's own persisted store, same + // classification as apply-atlas-card-create. The clipboard + // bridge's own taxonomy (clipbridge.MayAutoRun) already forces a + // human review-accept BEFORE any run reaches this node -- the + // items arriving here are the accepted subset, never the raw + // reply. + Effect: guardrail.ClassLocal, + Complexity: ComplexityAdvanced, + Output: "payload unchanged; a created-summary JSON -> the output attribute, if named", + Description: "Creates Atlas records from an accepted clipboard reply's items: an item with a " + + "\"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. " + + "\"Items\" binds the attribute holding the accepted items as a JSON array.", + ConfigFields: []ConfigField{ + { + Key: "itemsAttribute", Label: "Items attribute", Type: FieldText, + Description: "Which Attributes field carries the accepted reply items (a JSON array).", + }, + { + Key: "outputAttribute", Label: "Output attribute (optional)", Type: FieldText, + Description: "Which Attributes field receives a summary of what was created.", + }, + }, + }, execAtlasFromReply) +} + +func execAtlasFromReply(node Node, ctx ExecContext) (ExecContext, error) { + attr := node.Config["itemsAttribute"] + if attr == "" { + return ctx, fmt.Errorf("apply-atlas-from-reply: itemsAttribute is required") + } + raw, _ := ctx.Attributes[attr].(string) + if raw == "" { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q carries no items", attr) + } + var probe []json.RawMessage + if err := json.Unmarshal([]byte(raw), &probe); err != nil { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q is not a JSON array: %w", attr, err) + } + if len(probe) == 0 { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q carries an empty item list", attr) + } + + summary, err := atlasReplyMaterializerFn(raw, currentRunID(ctx.RunContext)) + if err != nil { + return ctx, fmt.Errorf("apply-atlas-from-reply: %w", err) + } + if outAttr := node.Config["outputAttribute"]; outAttr != "" { + if ctx.Attributes == nil { + ctx.Attributes = map[string]any{} + } + ctx.Attributes[outAttr] = summary + } + return ctx, nil +} diff --git a/internal/domain/composition/atlasfromreply_test.go b/internal/domain/composition/atlasfromreply_test.go new file mode 100644 index 00000000..253ff59f --- /dev/null +++ b/internal/domain/composition/atlasfromreply_test.go @@ -0,0 +1,60 @@ +package composition + +import ( + "strings" + "testing" +) + +func TestExecAtlasFromReply(t *testing.T) { + node := Node{Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}} + + t.Run("materializes via the injected function and stores the summary", func(t *testing.T) { + restore := atlasReplyMaterializerFn + defer func() { atlasReplyMaterializerFn = restore }() + var got string + atlasReplyMaterializerFn = func(itemsJSON, sourceRunID string) (string, error) { + got = itemsJSON + return `{"cards":1}`, nil + } + ctx := ExecContext{Attributes: map[string]any{"items": `[{"title":"CRM"}]`}} + out, err := execAtlasFromReply(node, ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `[{"title":"CRM"}]` { + t.Errorf("materializer received %q", got) + } + if out.Attributes["created"] != `{"cards":1}` { + t.Errorf("summary not stored: %+v", out.Attributes) + } + }) + + t.Run("missing attribute is a named error", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{}}) + if err == nil || !strings.Contains(err.Error(), "no items") { + t.Fatalf("expected a named no-items error, got %v", err) + } + }) + + t.Run("non-array payload refused", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{"items": `{"title":"x"}`}}) + if err == nil || !strings.Contains(err.Error(), "JSON array") { + t.Fatalf("expected a named array error, got %v", err) + } + }) + + t.Run("empty array refused", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{"items": `[]`}}) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected a named empty error, got %v", err) + } + }) + + t.Run("unwired materializer fails loudly", func(t *testing.T) { + ctx := ExecContext{Attributes: map[string]any{"items": `[{"title":"x"}]`}} + _, err := execAtlasFromReply(node, ctx) + if err == nil || !strings.Contains(err.Error(), "no atlas reply materializer") { + t.Fatalf("expected the unwired default to error, got %v", err) + } + }) +} diff --git a/internal/domain/composition/builtinworkflows.go b/internal/domain/composition/builtinworkflows.go index 9f3ff7d8..94ce0362 100644 --- a/internal/domain/composition/builtinworkflows.go +++ b/internal/domain/composition/builtinworkflows.go @@ -486,7 +486,10 @@ func BuiltInWorkflows() []Workflow { workflows = append(workflows, builtInListWriteWorkflows()...) // goal 0087: apply-file-move's own seeded proof, same split-file // reasoning. - return append(workflows, builtInFileMoveWorkflows()...) + workflows = append(workflows, builtInFileMoveWorkflows()...) + // goal 0099: the clipboard bridge's two seeded routes, same + // split-file reasoning. + return append(workflows, builtInClipbridgeWorkflows()...) } // ExampleChildWorkflowID is exported so the parent seed above and any diff --git a/internal/domain/composition/builtinworkflows_clipbridge.go b/internal/domain/composition/builtinworkflows_clipbridge.go new file mode 100644 index 00000000..4e1e7184 --- /dev/null +++ b/internal/domain/composition/builtinworkflows_clipbridge.go @@ -0,0 +1,75 @@ +package composition + +import "github.com/alicoding/mill/internal/domain/seedorigin" + +// The clipboard bridge's two seeded ROUTES (goal 0099): what the Quick +// Panel runs when the user accepts a validated reply. Routes are +// composition by contract -- seeded, user-editable workflows, never a +// bespoke service path -- so "same but different destination" is an +// edit here, not a kernel change. +const ( + // ReplyCardsWorkflowID is the create-cards route. + ReplyCardsWorkflowID = "clipbridge-reply-cards-workflow" + // ReplyNoteWorkflowID is the note-to-scratchpad route. + ReplyNoteWorkflowID = "clipbridge-reply-note-workflow" +) + +func builtInClipbridgeWorkflows() []Workflow { + const ( + cardsTriggerID = "clipbridge-reply-cards-trigger" + cardsApplyID = "clipbridge-reply-cards-apply" + noteTriggerID = "clipbridge-reply-note-trigger" + noteApplyID = "clipbridge-reply-note-apply" + ) + cardsNodes, err := ResolveNodeDefaults([]Node{ + {ID: cardsTriggerID, NodeTypeID: "trigger-manual", Position: Position{X: 0, Y: 0}}, + {ID: cardsApplyID, NodeTypeID: "apply-atlas-from-reply", Position: Position{X: 0, Y: 100}, + Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}}, + }) + if err != nil { + panic("built-in workflow references an unknown node type: " + err.Error()) + } + noteNodes, err := ResolveNodeDefaults([]Node{ + {ID: noteTriggerID, NodeTypeID: "trigger-manual", Position: Position{X: 0, Y: 0}}, + {ID: noteApplyID, NodeTypeID: "apply-atlas-from-reply", Position: Position{X: 0, Y: 100}, + Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}}, + }) + if err != nil { + panic("built-in workflow references an unknown node type: " + err.Error()) + } + + return []Workflow{ + { + ID: ReplyCardsWorkflowID, + Label: "Clipboard reply: create cards", + Description: "Creates the Atlas cards a reviewed clipboard reply proposes. Runs from the Quick " + + "Panel after you accept the preview -- the accepted items arrive in the \"items\" input. " + + "Edit this workflow to change where accepted cards go or what happens after they land.", + Nodes: cardsNodes, + Attributes: []AttributeDef{ + {Key: "items", Label: "Accepted items (JSON)", Type: FieldText}, + }, + Edges: []Edge{ + {ID: "clipbridge-reply-cards-e0", Source: cardsTriggerID, Target: cardsApplyID}, + }, + BuiltIn: true, + Seed: seedorigin.Stamp(1), + }, + { + ID: ReplyNoteWorkflowID, + Label: "Clipboard reply: save to Scratchpad", + Description: "Saves a reviewed clipboard reply's text into the Scratchpad as a note. Runs from " + + "the Quick Panel after you accept the preview. Edit this workflow to route the note " + + "somewhere else or add follow-up steps.", + Nodes: noteNodes, + Attributes: []AttributeDef{ + {Key: "items", Label: "Accepted items (JSON)", Type: FieldText}, + }, + Edges: []Edge{ + {ID: "clipbridge-reply-note-e0", Source: noteTriggerID, Target: noteApplyID}, + }, + BuiltIn: true, + Seed: seedorigin.Stamp(1), + }, + } +} diff --git a/internal/domain/composition/seedproof_test.go b/internal/domain/composition/seedproof_test.go index c9e4de7a..35ca4fef 100644 --- a/internal/domain/composition/seedproof_test.go +++ b/internal/domain/composition/seedproof_test.go @@ -152,6 +152,13 @@ var workflowProofRegistry = map[string]seedProof{ "executionsvc.TestSeededCardCreateLinkExample_CreatesFindsAndLinksCards", "e2e: seed-completeness.spec.ts > Example: Create and link Atlas cards runs end to end through the real live app", ), + "clipbridge-reply-cards-workflow": proven( + "executionsvc.TestSeededClipbridgeCardsRoute_CreatesAcceptedCards", + "executionsvc.TestClipbridgePreviewToRouteLoop (the preview-to-route loop over the same seed)", + ), + "clipbridge-reply-note-workflow": proven( + "executionsvc.TestSeededClipbridgeNoteRoute_LandsInScratchpad", + ), "example-list-write-workflow": proven( "executionsvc.TestGuardrail_ApplyListRowParks_ApproveWritesRow", "executionsvc.TestSeededTaskTrackerExample_PinnedSearch_ResolvesFrozenV1AfterLiveWrite (via the write-path half of the same test)", diff --git a/internal/services/atlassvc/atlasservice_clipbridge.go b/internal/services/atlassvc/atlasservice_clipbridge.go new file mode 100644 index 00000000..b3c7b4d8 --- /dev/null +++ b/internal/services/atlassvc/atlasservice_clipbridge.go @@ -0,0 +1,228 @@ +package atlassvc + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/domain/clipbridge" + "github.com/alicoding/mill/internal/domain/composition" +) + +// The clipboard bridge (goal 0099) lives beside AtlasService because +// every dynamic part of the protocol is Atlas's own data: the kind +// labels the reply schema enumerates, the card titles collision +// detection compares against, and the records an accepted reply +// materializes into. The protocol itself stays in domain/clipbridge. + +// ClipbridgeCardOffer is one to-be-created card row on the review +// surface: the parsed draft plus its collision flag (the dedupe +// convention -- colliding rows default unchecked). +type ClipbridgeCardOffer struct { + Draft clipbridge.CardDraft `json:"Draft"` + CollidesWithID string `json:"CollidesWithID"` + CollidesWithKind string `json:"CollidesWithKind"` +} + +// ClipbridgeReplyPreview is what the Quick Panel renders when the +// clipboard carries a mill reply: the domain preview plus the +// Atlas-side collision annotations and the route workflow to run on +// accept. +type ClipbridgeReplyPreview struct { + Recognized bool `json:"Recognized"` + Valid bool `json:"Valid"` + Action string `json:"Action"` + Errors []string `json:"Errors"` + Cards []ClipbridgeCardOffer `json:"Cards"` + NoteTexts []string `json:"NoteTexts"` + RouteWorkflowID string `json:"RouteWorkflowID"` + RouteLabel string `json:"RouteLabel"` +} + +// PreviewClipbridgeReply validates a raw clipboard string against the +// reply contract (schema-first, then per-action requirements) and +// annotates it with collision state. Malformed input renders inline -- +// this returns a Go error only for internal faults. +func (a *AtlasService) PreviewClipbridgeReply(raw string) (ClipbridgeReplyPreview, error) { + kinds := a.Kinds() + labels := make([]string, 0, len(kinds)) + for _, k := range kinds { + labels = append(labels, k.Label) + } + sort.Strings(labels) + + p := clipbridge.ParseReply(raw, labels, clipbridge.V1Actions()) + out := ClipbridgeReplyPreview{ + Recognized: p.Recognized, + Valid: p.Valid, + Action: p.Action, + Errors: p.Errors, + NoteTexts: p.NoteTexts, + } + if !p.Recognized { + return out, nil + } + switch p.Action { + case clipbridge.ActionCreateCards: + out.RouteWorkflowID = composition.ReplyCardsWorkflowID + out.RouteLabel = fmt.Sprintf("Create %d cards", len(p.Cards)) + if len(p.Cards) == 1 { + out.RouteLabel = "Create 1 card" + } + case clipbridge.ActionNoteToScratchpad: + out.RouteWorkflowID = composition.ReplyNoteWorkflowID + out.RouteLabel = "Save to Scratchpad" + } + + byTitle := map[string]atlas.Card{} + for _, c := range a.Cards() { + byTitle[strings.ToLower(strings.TrimSpace(c.Title))] = c + } + kindLabelByID := map[string]string{} + for _, k := range kinds { + kindLabelByID[k.ID] = k.Label + } + for _, d := range p.Cards { + offer := ClipbridgeCardOffer{Draft: d} + if existing, ok := byTitle[strings.ToLower(strings.TrimSpace(d.Title))]; ok { + offer.CollidesWithID = existing.ID + offer.CollidesWithKind = kindLabelByID[existing.KindID] + } + out.Cards = append(out.Cards, offer) + } + return out, nil +} + +// CardContextEnvelope renders a card as the OUT envelope (goal 0099): +// its data as items, the reply contract inline. The plain-text +// CardContextBlock stays for human destinations; this is the +// machine-readable twin an external AI answers against. +func (a *AtlasService) CardContextEnvelope(cardID string) (string, error) { + a.mu.RLock() + in, err := a.cardContextInputLocked(cardID) + if err != nil { + a.mu.RUnlock() + return "", err + } + labels := make([]string, 0, len(a.kinds)) + for _, k := range a.kinds { + labels = append(labels, k.Label) + } + a.mu.RUnlock() + sort.Strings(labels) + + ctxCard := clipbridge.ContextCard{Title: in.title, Kind: in.kindLabel, Note: in.note} + if len(in.fields) > 0 { + ctxCard.Fields = make(map[string]string, len(in.fields)) + for _, f := range in.fields { + ctxCard.Fields[f.label] = f.value + } + } + for _, l := range in.outgoing { + ctxCard.Links = append(ctxCard.Links, clipbridge.ContextLink{Kind: l.linkKindLabel, Direction: "out", Title: l.otherTitle}) + } + for _, l := range in.incoming { + ctxCard.Links = append(ctxCard.Links, clipbridge.ContextLink{Kind: l.linkKindLabel, Direction: "in", Title: l.otherTitle}) + } + + env, err := clipbridge.BuildContextEnvelope([]clipbridge.ContextCard{ctxCard}, labels, clipbridge.V1Actions()) + if err != nil { + return "", err + } + raw, err := json.MarshalIndent(env, "", " ") + if err != nil { + return "", err + } + return string(raw), nil +} + +// materializeReplyItems is the apply-atlas-from-reply seam: accepted +// reply items become real records. An item with a title becomes a card +// (kind label resolved against the CURRENT kinds; summary lands in the +// kind's own "summary" field when it declares one, otherwise appended +// to the note so nothing is silently dropped); an item with text +// becomes a Scratchpad note. +func (a *AtlasService) materializeReplyItems(itemsJSON string, sourceRunID string) (string, error) { + var items []struct { + Title string `json:"title"` + Kind string `json:"kind"` + Note string `json:"note"` + Summary string `json:"summary"` + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil { + return "", fmt.Errorf("reply items are not a JSON array: %w", err) + } + + kinds := a.Kinds() + if len(kinds) == 0 { + return "", fmt.Errorf("no card kinds exist to create into") + } + kindByLabel := map[string]atlas.Kind{} + for _, k := range kinds { + kindByLabel[strings.ToLower(k.Label)] = k + } + // A draft without a kind takes the first declared kind -- the + // schema's enum already restricts named kinds, so this only covers + // the omitted case. + defaultKind := kinds[0] + + var ids []string + cards, notes := 0, 0 + for i, item := range items { + switch { + case strings.TrimSpace(item.Title) != "": + kind := defaultKind + if item.Kind != "" { + k, ok := kindByLabel[strings.ToLower(item.Kind)] + if !ok { + return "", fmt.Errorf("item %d: no kind labeled %q", i+1, item.Kind) + } + kind = k + } + note := item.Note + fields := map[string]string{} + switch { + case item.Summary == "": + case kindDeclaresField(kind, "summary"): + fields["summary"] = item.Summary + case note == "": + note = item.Summary + default: + note = note + "\n\n" + item.Summary + } + card, err := a.CreateCardForWorkflow(kind.ID, item.Title, note, fields, sourceRunID) + if err != nil { + return "", fmt.Errorf("item %d (%q): %w", i+1, item.Title, err) + } + ids = append(ids, card.ID) + cards++ + case strings.TrimSpace(item.Text) != "": + n, err := a.CreateNote(item.Text, atlas.Position{}, atlas.BuiltInScratchpadCardID) + if err != nil { + return "", fmt.Errorf("item %d (note): %w", i+1, err) + } + ids = append(ids, n.ID) + notes++ + default: + return "", fmt.Errorf("item %d carries neither a title nor text", i+1) + } + } + + summary, err := json.Marshal(map[string]any{"cards": cards, "notes": notes, "ids": ids}) + if err != nil { + return "", err + } + return string(summary), nil +} + +func kindDeclaresField(k atlas.Kind, key string) bool { + for _, f := range k.Fields { + if strings.EqualFold(f.Key, key) { + return true + } + } + return false +} diff --git a/internal/services/atlassvc/atlasservice_clipbridge_test.go b/internal/services/atlassvc/atlasservice_clipbridge_test.go new file mode 100644 index 00000000..a590a4bd --- /dev/null +++ b/internal/services/atlassvc/atlasservice_clipbridge_test.go @@ -0,0 +1,156 @@ +package atlassvc + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/services/servicetest" +) + +func TestPreviewClipbridgeReply(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + + t.Run("prose is not recognized", func(t *testing.T) { + p, err := a.PreviewClipbridgeReply("meeting notes, nothing structured") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Recognized { + t.Fatalf("prose recognized: %+v", p) + } + }) + + t.Run("valid create-cards reply routes and flags collisions", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"title":"Getting started"},{"title":"Brand new"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p.Valid || p.RouteWorkflowID == "" || !strings.Contains(p.RouteLabel, "2 cards") { + t.Fatalf("preview = %+v", p) + } + if p.Cards[0].CollidesWithID == "" || p.Cards[1].CollidesWithID != "" { + t.Fatalf("collision flags wrong: %+v", p.Cards) + } + }) + + t.Run("invalid reply names its failures", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"note":"missing title"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Valid || len(p.Errors) == 0 { + t.Fatalf("expected named errors: %+v", p) + } + }) + + t.Run("note reply routes to the scratchpad workflow", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"note-to-scratchpad","items":[{"text":"remember"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p.Valid || p.RouteLabel != "Save to Scratchpad" || len(p.NoteTexts) != 1 { + t.Fatalf("preview = %+v", p) + } + }) +} + +func TestCardContextEnvelope(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + var seedCard atlas.Card + for _, c := range a.Cards() { + if c.Title == "Getting started" { + seedCard = c + } + } + if seedCard.ID == "" { + t.Fatal("seeded card missing") + } + + raw, err := a.CardContextEnvelope(seedCard.ID) + if err != nil { + t.Fatalf("CardContextEnvelope: %v", err) + } + var env map[string]any + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("envelope is not JSON: %v", err) + } + if env["mill"] != float64(1) || env["kind"] != "context" { + t.Fatalf("marker wrong: %v %v", env["mill"], env["kind"]) + } + items, _ := env["items"].([]any) + if len(items) != 1 { + t.Fatalf("items = %v", env["items"]) + } + first, _ := items[0].(map[string]any) + if first["title"] != "Getting started" { + t.Fatalf("item = %v", first) + } + if _, ok := env["schema"].(map[string]any); !ok { + t.Fatal("inline schema missing") + } + + if _, err := a.CardContextEnvelope("no-such-card"); err == nil { + t.Fatal("unknown card must error") + } +} + +func TestMaterializeReplyItems(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + + t.Run("cards and notes in one batch", func(t *testing.T) { + summary, err := a.materializeReplyItems(`[{"title":"Batch card","note":"n"},{"text":"batch note"}]`, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var s struct { + Cards int `json:"cards"` + Notes int `json:"notes"` + IDs []string `json:"ids"` + } + if err := json.Unmarshal([]byte(summary), &s); err != nil { + t.Fatalf("summary not JSON: %v", err) + } + if s.Cards != 1 || s.Notes != 1 || len(s.IDs) != 2 { + t.Fatalf("summary = %+v", s) + } + }) + + t.Run("summary maps to a declared field or appends to the note", func(t *testing.T) { + summary, err := a.materializeReplyItems(`[{"title":"With summary","summary":"the gist"}]`, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _ = summary + var made atlas.Card + for _, c := range a.Cards() { + if c.Title == "With summary" { + made = c + } + } + if made.ID == "" { + t.Fatal("card missing") + } + if made.Fields["summary"] == "" && !strings.Contains(made.Note, "the gist") { + t.Fatalf("summary lost: %+v", made) + } + }) + + t.Run("unknown kind label errors with its name", func(t *testing.T) { + _, err := a.materializeReplyItems(`[{"title":"x","kind":"Nonesuch"}]`, "") + if err == nil || !strings.Contains(err.Error(), "Nonesuch") { + t.Fatalf("expected a named kind error, got %v", err) + } + }) + + t.Run("item with neither title nor text errors", func(t *testing.T) { + _, err := a.materializeReplyItems(`[{"note":"only a note"}]`, "") + if err == nil { + t.Fatal("expected an error") + } + }) +} diff --git a/internal/services/atlassvc/atlasservice_composition.go b/internal/services/atlassvc/atlasservice_composition.go index f31150c2..bdf50b85 100644 --- a/internal/services/atlassvc/atlasservice_composition.go +++ b/internal/services/atlassvc/atlasservice_composition.go @@ -24,6 +24,7 @@ func (a *AtlasService) WireCompositionSeams(cardChangeSink func(cardID, kindID, composition.SetAtlasCardCreator(a.createCardForComposition) composition.SetAtlasCardUpdater(a.updateCardForComposition) composition.SetAtlasCardLinker(a.linkCardsForComposition) + composition.SetAtlasReplyMaterializer(a.materializeReplyItems) SetCardChangeSink(cardChangeSink) } diff --git a/internal/services/executionsvc/clipbridge_seed_test.go b/internal/services/executionsvc/clipbridge_seed_test.go new file mode 100644 index 00000000..c7150819 --- /dev/null +++ b/internal/services/executionsvc/clipbridge_seed_test.go @@ -0,0 +1,142 @@ +package executionsvc + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/domain/composition" + "github.com/alicoding/mill/internal/services/atlassvc" + "github.com/alicoding/mill/internal/services/compositionsvc" + "github.com/alicoding/mill/internal/services/guardrailsvc" + "github.com/alicoding/mill/internal/services/servicetest" +) + +func newClipbridgeSeedFixture(t *testing.T) (*ExecutionService, *atlassvc.AtlasService) { + t.Helper() + store := servicetest.NewFakeStore() + comp := compositionsvc.NewCompositionService(store) + guard := guardrailsvc.NewGuardrailService(store, comp) + dbPath := filepath.Join(t.TempDir(), "exec.db") + exec, err := NewExecutionService("sqlite:"+dbPath, comp, guard) + if err != nil { + t.Fatalf("NewExecutionService: %v", err) + } + t.Cleanup(func() { _ = exec.Shutdown(2 * time.Second) }) + atlasSvc := atlassvc.NewAtlasService(store) + atlasSvc.WireCompositionSeams(func(string, string, string, string, string) {}) + return exec, atlasSvc +} + +// TestSeededClipbridgeCardsRoute_CreatesAcceptedCards runs the real +// seeded "Clipboard reply: create cards" route (goal 0099) end to end: +// accepted items in, real cards out, including the summary-field +// mapping and the kind-label resolution. +func TestSeededClipbridgeCardsRoute_CreatesAcceptedCards(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + baseline := len(atlasSvc.Cards()) + items := `[{"title":"Payments gateway","kind":"Topic","note":"from the AI reply"},{"title":"Ada follow-up"}]` + summary, err := exec.RunWorkflow(composition.ReplyCardsWorkflowID, RunKindTest, map[string]string{"items": items}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + cards := atlasSvc.Cards() + if len(cards) != baseline+2 { + t.Fatalf("got %d cards, want %d", len(cards), baseline+2) + } + var made atlas.Card + for _, c := range cards { + if c.Title == "Payments gateway" { + made = c + } + } + if made.ID == "" || made.Note != "from the AI reply" { + t.Fatalf("created card wrong: %+v", made) + } +} + +// TestSeededClipbridgeNoteRoute_LandsInScratchpad runs the seeded +// "Clipboard reply: save to Scratchpad" route: the note lands under the +// seeded Scratchpad area, never floating at root. +func TestSeededClipbridgeNoteRoute_LandsInScratchpad(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + items := `[{"text":"remember: renew the cert"}]` + summary, err := exec.RunWorkflow(composition.ReplyNoteWorkflowID, RunKindTest, map[string]string{"items": items}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + var found bool + for _, n := range atlasSvc.Notes() { + if strings.Contains(n.Text, "renew the cert") { + found = true + if n.ParentID != atlas.BuiltInScratchpadCardID { + t.Fatalf("note landed under %q, want the Scratchpad", n.ParentID) + } + } + } + if !found { + t.Fatal("the reply's note never landed") + } +} + +// TestClipbridgePreviewToRouteLoop closes the whole IN loop at the +// service level: a raw reply string previews (with collision +// annotation), and the preview's own route workflow materializes the +// accepted items. +func TestClipbridgePreviewToRouteLoop(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + // "Getting started" exists in the seed -- a reply proposing it + // again must flag the collision. + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"title":"Getting started"},{"title":"Fresh card"}]}` + preview, err := atlasSvc.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("PreviewClipbridgeReply: %v", err) + } + if !preview.Valid || preview.RouteWorkflowID != composition.ReplyCardsWorkflowID { + t.Fatalf("preview = %+v", preview) + } + if len(preview.Cards) != 2 { + t.Fatalf("got %d offers, want 2", len(preview.Cards)) + } + if preview.Cards[0].CollidesWithID == "" { + t.Errorf("existing title must flag its collision: %+v", preview.Cards[0]) + } + if preview.Cards[1].CollidesWithID != "" { + t.Errorf("fresh title must not flag: %+v", preview.Cards[1]) + } + + // Accept only the non-colliding item (the review surface's default) + // and run the preview's own route. + accepted, _ := json.Marshal([]map[string]string{{"title": preview.Cards[1].Draft.Title}}) + summary, err := exec.RunWorkflow(preview.RouteWorkflowID, RunKindTest, map[string]string{"items": string(accepted)}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + var count int + for _, c := range atlasSvc.Cards() { + if c.Title == "Fresh card" { + count++ + } + if c.Title == "Getting started" { + count += 0 + } + } + if count != 1 { + t.Fatalf("accepted card count = %d, want exactly 1", count) + } +} diff --git a/internal/services/seeding/seed_fingerprints.json b/internal/services/seeding/seed_fingerprints.json index f8557e17..2cc7a1e6 100644 --- a/internal/services/seeding/seed_fingerprints.json +++ b/internal/services/seeding/seed_fingerprints.json @@ -1,230 +1,238 @@ { "aiprovider:example-local-ollama": { - "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100" }, "atlascard:atlas-card-example-area": { - "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4", - "seedRevision": 5 + "seedRevision": 5, + "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4" }, "atlascard:atlas-card-example-contact": { - "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f", - "seedRevision": 4 + "seedRevision": 4, + "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f" }, "atlascard:atlas-card-example-document": { - "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5", - "seedRevision": 5 + "seedRevision": 5, + "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5" }, "atlascard:atlas-card-getting-started": { - "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd", - "seedRevision": 5 + "seedRevision": 5, + "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd" }, "atlascard:atlas-card-my-space": { - "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7", - "seedRevision": 5 + "seedRevision": 5, + "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7" }, "atlascard:atlas-card-scratchpad": { - "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1", - "seedRevision": 5 + "seedRevision": 5, + "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1" }, "atlaskind:atlas-kind-contact": { - "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732" }, "atlaskind:atlas-kind-document": { - "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a" }, "atlaskind:atlas-kind-intake": { - "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5" }, "atlaskind:atlas-kind-reference": { - "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89" }, "atlaskind:atlas-kind-topic": { - "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e" }, "atlaslink:atlas-link-contact-to-document": { - "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae" }, "atlaslink:atlas-link-getting-to-contact": { - "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a" }, "atlaslinkkind:atlas-linkkind-relates-to": { - "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379" }, "decision:example-approve-decision": { - "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34", - "seedRevision": 3 + "seedRevision": 3, + "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34" }, "decision:example-deny-decision": { - "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604", - "seedRevision": 3 + "seedRevision": 3, + "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604" }, "decision:example-manual-review-decision": { - "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37", - "seedRevision": 3 + "seedRevision": 3, + "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37" }, "execenv:example-safe-sandbox-execenv": { - "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc" }, "list:example-country-codes-list": { - "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755", - "seedRevision": 4 + "seedRevision": 4, + "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755" }, "list:example-task-tracker-list": { - "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3" }, "mcpserver:example-reference-mcpserver": { - "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549" }, "request:example-apikey-httpbin": { - "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e" }, "request:example-bearer-httpbin": { - "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2" }, "request:example-hmac-httpbin": { - "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1" }, "request:example-none-httpbin": { - "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf" }, "request:example-oauth1-postman-echo": { - "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a" }, "request:example-oauth2-spotify": { - "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d" }, "request:example-queryparam-httpbin": { - "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61" }, "workflow:backup-mill-data-workflow": { - "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55" }, "workflow:clipboard-html-to-markdown-workflow": { - "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397" + }, + "workflow:clipbridge-reply-cards-workflow": { + "seedRevision": 1, + "fingerprint": "290efbf6399960481af8b587544808ac9ba4d032f00889ccdeec886cd5f082c5" + }, + "workflow:clipbridge-reply-note-workflow": { + "seedRevision": 1, + "fingerprint": "0db57006c8193c338bcccfb23befd063b487367cc859a44069118d43e590433d" }, "workflow:example-ai-classify-branch-workflow": { - "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c" }, "workflow:example-ai-summarize-workflow": { - "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e" }, "workflow:example-branch-to-decision-workflow": { - "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0", - "seedRevision": 3 + "seedRevision": 3, + "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0" }, "workflow:example-card-create-link-workflow": { - "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a" }, "workflow:example-card-intake-workflow": { - "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004" }, "workflow:example-child-echo-workflow": { - "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8" }, "workflow:example-clipboard-inspector-workflow": { - "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287" }, "workflow:example-codeexec-workflow": { - "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586" }, "workflow:example-decision-with-review-workflow": { - "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c", - "seedRevision": 3 + "seedRevision": 3, + "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c" }, "workflow:example-disabled-filesystem-watch-workflow": { - "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017" }, "workflow:example-disabled-schedule-workflow": { - "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da" }, "workflow:example-filemove-workflow": { - "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06" }, "workflow:example-forward-approvals-workflow": { - "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d" }, "workflow:example-guarded-http-workflow": { - "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f" }, "workflow:example-list-lookup-workflow": { - "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134" }, "workflow:example-list-pinned-workflow": { - "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708" }, "workflow:example-list-search-workflow": { - "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1" }, "workflow:example-list-write-workflow": { - "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3" }, "workflow:example-mcp-echo-workflow": { - "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666" }, "workflow:example-parent-workflow": { - "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2" }, "workflow:example-review-workflow": { - "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2" }, "workflow:example-run-receipt-workflow": { - "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6" }, "workflow:example-saved-page-to-markdown-workflow": { - "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8" }, "workflow:example-scratch-capture-workflow": { - "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c", - "seedRevision": 2 + "seedRevision": 2, + "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c" }, "workflow:example-step-failure-workflow": { - "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf" }, "workflow:load-sample-html-workflow": { - "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214", - "seedRevision": 1 + "seedRevision": 1, + "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214" } } From d521e5e3d2d01630e74d27caf247ee795bc60375 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 08:08:21 -0400 Subject: [PATCH 2/5] feat: clipbridge correction envelope -- the re-ask-the-source loop's binding; bindings regenerated Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- .../mill/internal/domain/clipbridge/index.ts | 6 ++++ .../mill/internal/domain/clipbridge/models.ts | 13 ++++++++ .../services/atlassvc/atlasservice.ts | 29 +++++++++++++++++ .../mill/internal/services/atlassvc/index.ts | 2 ++ .../mill/internal/services/atlassvc/models.ts | 31 +++++++++++++++++++ internal/domain/clipbridge/envelope.go | 29 +++++++++++++++++ .../atlassvc/atlasservice_clipbridge.go | 21 +++++++++++++ .../atlassvc/atlasservice_clipbridge_test.go | 18 +++++++++++ 8 files changed, 149 insertions(+) create mode 100644 frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts create mode 100644 frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts new file mode 100644 index 00000000..58270c2a --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + CardDraft +} from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts new file mode 100644 index 00000000..a885c671 --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts @@ -0,0 +1,13 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * CardDraft is one to-be-created card parsed from a valid create-cards + * reply -- exactly what the review surface previews. + */ +export interface CardDraft { + "title": string; + "kind"?: string; + "note"?: string; + "summary"?: string; +} diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts index f505b28f..7c6c60e3 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts @@ -71,6 +71,16 @@ export function CardContextBlock(cardID: string, withAttachments: boolean): $Can return $Call.ByID(4150547692, cardID, withAttachments); } +/** + * CardContextEnvelope renders a card as the OUT envelope (goal 0099): + * its data as items, the reply contract inline. The plain-text + * CardContextBlock stays for human destinations; this is the + * machine-readable twin an external AI answers against. + */ +export function CardContextEnvelope(cardID: string): $CancellablePromise { + return $Call.ByID(1724710751, cardID); +} + /** * Cards returns every LIVE card (goal 0093: a tombstoned card is * excluded, and a live child of a tombstoned container carries its @@ -99,6 +109,15 @@ export function ConvertHTMLToMarkdown(html: string): $CancellablePromise return $Call.ByID(989919804, html); } +/** + * CorrectionEnvelope re-emits the reply contract for the + * re-ask-the-source loop: validation problems and declined titles ride + * the instruction line, the schema stays the instruction. + */ +export function CorrectionEnvelope(problems: string[] | null, declinedTitles: string[] | null): $CancellablePromise { + return $Call.ByID(4009289398, problems, declinedTitles); +} + /** * CreateCard makes a new Card of kindID, optionally inside parentID * ("" for root-level). A non-empty parentID must name an existing @@ -412,6 +431,16 @@ export function PickFolder(startDir: string): $CancellablePromise { return $Call.ByID(3623587391, startDir); } +/** + * PreviewClipbridgeReply validates a raw clipboard string against the + * reply contract (schema-first, then per-action requirements) and + * annotates it with collision state. Malformed input renders inline -- + * this returns a Go error only for internal faults. + */ +export function PreviewClipbridgeReply(raw: string): $CancellablePromise<$models.ClipbridgeReplyPreview> { + return $Call.ByID(3851574839, raw); +} + /** * PromoteNote is the note's one-way lifecycle event (the LOCKED * design's "promotion ritual"): it becomes a typed Card in place -- diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts index ebef6a44..57507a2d 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts @@ -9,6 +9,8 @@ export { export type { AtlasImportSummary, AtlasSessionState, + ClipbridgeCardOffer, + ClipbridgeReplyPreview, FileDropCreateResult, FileDropRoute, FolderImportSummary, diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts index 371ac994..ea61939f 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts @@ -4,6 +4,9 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as atlas$0 from "../../domain/atlas/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as clipbridge$0 from "../../domain/clipbridge/models.js"; /** * AtlasImportSummary counts what ImportAtlas did, per family -- the @@ -47,6 +50,34 @@ export interface AtlasSessionState { "activePerspectiveID": string; } +/** + * ClipbridgeCardOffer is one to-be-created card row on the review + * surface: the parsed draft plus its collision flag (the dedupe + * convention -- colliding rows default unchecked). + */ +export interface ClipbridgeCardOffer { + "Draft": clipbridge$0.CardDraft; + "CollidesWithID": string; + "CollidesWithKind": string; +} + +/** + * ClipbridgeReplyPreview is what the Quick Panel renders when the + * clipboard carries a mill reply: the domain preview plus the + * Atlas-side collision annotations and the route workflow to run on + * accept. + */ +export interface ClipbridgeReplyPreview { + "Recognized": boolean; + "Valid": boolean; + "Action": string; + "Errors": string[] | null; + "Cards": ClipbridgeCardOffer[] | null; + "NoteTexts": string[] | null; + "RouteWorkflowID": string; + "RouteLabel": string; +} + /** * FileDropCreateResult is CreateCardFromFileDrop's own response -- * wraps the newly created card with an additive duplicate-detection diff --git a/internal/domain/clipbridge/envelope.go b/internal/domain/clipbridge/envelope.go index e9d25d78..e051a468 100644 --- a/internal/domain/clipbridge/envelope.go +++ b/internal/domain/clipbridge/envelope.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" ) // EnvelopeVersion is the "mill" marker value; a reply carrying a @@ -82,3 +83,31 @@ func BuildContextEnvelope(cards []ContextCard, kindLabels []string, actions []st Items: items, }, nil } + +// BuildCorrectionEnvelope re-emits the reply contract after a failed or +// partially-declined reply -- the correction loop is re-ask-the-source, +// never edit-in-place, so the envelope itself carries what went wrong +// and what the user declined, and the schema remains the instruction. +func BuildCorrectionEnvelope(problems []string, declinedTitles []string, kindLabels []string, actions []string) (Envelope, error) { + schema, err := ReplySchema(kindLabels, actions) + if err != nil { + return Envelope{}, err + } + instructions := replyInstructions + if len(problems) > 0 { + instructions = "Your previous reply did not validate: " + strings.Join(problems, "; ") + ". " + replyInstructions + } + if len(declinedTitles) > 0 { + instructions += " Do not propose these declined items again: " + strings.Join(declinedTitles, ", ") + "." + } + sorted := append([]string(nil), actions...) + sort.Strings(sorted) + return Envelope{ + Mill: EnvelopeVersion, + Kind: KindContext, + Instructions: instructions, + Schema: schema, + AllowedActions: sorted, + Items: []json.RawMessage{}, + }, nil +} diff --git a/internal/services/atlassvc/atlasservice_clipbridge.go b/internal/services/atlassvc/atlasservice_clipbridge.go index b3c7b4d8..24ad9ebb 100644 --- a/internal/services/atlassvc/atlasservice_clipbridge.go +++ b/internal/services/atlassvc/atlasservice_clipbridge.go @@ -226,3 +226,24 @@ func kindDeclaresField(k atlas.Kind, key string) bool { } return false } + +// CorrectionEnvelope re-emits the reply contract for the +// re-ask-the-source loop: validation problems and declined titles ride +// the instruction line, the schema stays the instruction. +func (a *AtlasService) CorrectionEnvelope(problems []string, declinedTitles []string) (string, error) { + kinds := a.Kinds() + labels := make([]string, 0, len(kinds)) + for _, k := range kinds { + labels = append(labels, k.Label) + } + sort.Strings(labels) + env, err := clipbridge.BuildCorrectionEnvelope(problems, declinedTitles, labels, clipbridge.V1Actions()) + if err != nil { + return "", err + } + raw, err := json.MarshalIndent(env, "", " ") + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/internal/services/atlassvc/atlasservice_clipbridge_test.go b/internal/services/atlassvc/atlasservice_clipbridge_test.go index a590a4bd..702e7e83 100644 --- a/internal/services/atlassvc/atlasservice_clipbridge_test.go +++ b/internal/services/atlassvc/atlasservice_clipbridge_test.go @@ -154,3 +154,21 @@ func TestMaterializeReplyItems(t *testing.T) { } }) } + +func TestCorrectionEnvelope(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + raw, err := a.CorrectionEnvelope([]string{"item 1: a card needs a non-empty \"title\""}, []string{"Getting started"}) + if err != nil { + t.Fatalf("CorrectionEnvelope: %v", err) + } + if !strings.Contains(raw, "did not validate") || !strings.Contains(raw, "Getting started") { + t.Fatalf("instructions incomplete: %s", raw[:200]) + } + var env map[string]any + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("not JSON: %v", err) + } + if _, ok := env["schema"].(map[string]any); !ok { + t.Fatal("schema missing") + } +} From 3c3ecd9f41398ee0f0e282695e9071dbb7aa8d51 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 08:20:56 -0400 Subject: [PATCH 3/5] feat: clipboard bridge frontend -- one clipboard door, the reply review surface, Copy for AI (goal 0099) The Quick Panel's Apply-from-clipboard row now recognizes both payload families (workflow export first, then the mill reply envelope) via a shared clipboard-door hook, split out to keep QuickPanel under the 500-line cap. A recognized reply swaps the panel into the review surface (the fourth instance of the preview-dialog language): creates render as checkbox rows, colliding titles default unchecked with the collision named, the confirm carries its count, nothing executes before accept, and the run goes through the seeded route workflow. The correction loop is re-ask-the-source (Copy corrected context). The card page's share rail gains Copy for AI, the machine-readable twin of Copy as context. e2e covers the envelope round-trip, the collision-unchecked accept flow, and the invalid-reply correction copy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/clipboard-bridge.spec.ts | 107 +++++++++++ frontend/src/app/QuickPanel.tsx | 58 +++--- frontend/src/app/QuickPanelReplyReview.tsx | 168 ++++++++++++++++++ .../src/app/useQuickPanelClipboardDoor.ts | 48 +++++ frontend/src/atlas/AtlasCardOverlay.tsx | 9 + frontend/src/atlas/AtlasCardPageMetaRail.tsx | 7 +- frontend/src/atlas/atlasCardShare.ts | 11 +- frontend/src/locales/en/app.json | 17 +- frontend/src/locales/en/atlas.json | 5 +- frontend/src/shared/bindings.ts | 4 + 10 files changed, 393 insertions(+), 41 deletions(-) create mode 100644 frontend/e2e/clipboard-bridge.spec.ts create mode 100644 frontend/src/app/QuickPanelReplyReview.tsx create mode 100644 frontend/src/app/useQuickPanelClipboardDoor.ts diff --git a/frontend/e2e/clipboard-bridge.spec.ts b/frontend/e2e/clipboard-bridge.spec.ts new file mode 100644 index 00000000..4ad4f9e7 --- /dev/null +++ b/frontend/e2e/clipboard-bridge.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from './fixtures/server' +import { withClipboardLock } from './fixtures/clipboardLock' +import { noteCard, openCard } from './fixtures/atlasBoard' +import { deleteViaPageMenu } from './fixtures/atlasPage' + +// The clipboard bridge (goal 0099): Copy for AI emits the JSON +// envelope; the Quick Panel's clipboard door recognizes a reply, +// renders the review surface (collisions default-unchecked), and the +// accept runs the seeded route workflow. Shared worker pool: every +// entity this file creates it deletes; clipboard sections take the +// cross-process lock per testing.md's real-pasteboard discipline. + +async function openPanelWithClipboard(page: import('@playwright/test').Page, payload: string) { + await page.goto('about:blank') + await page.goto('/#/quickpanel') + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.evaluate((t) => navigator.clipboard.writeText(t), payload) + const search = page.getByRole('combobox', { name: 'Quick Panel search' }) + await expect(search).toBeFocused() + await search.fill('apply from clipboard') + const option = page.getByRole('option', { name: 'Apply from clipboard…' }) + await expect(option).toBeVisible() + await option.click() +} + +test('Copy for AI puts the reply-contract envelope on the clipboard', async ({ page }) => { + await withClipboardLock(async () => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + + const card = noteCard(page, 'Getting started') + await openCard(page, card) + await page.getByTestId('atlas-overlay-copy-for-ai').click() + + // The copy handler's binding round-trip + clipboard write are + // async relative to the click -- poll until the envelope lands. + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mill"') + const raw = await page.evaluate(() => navigator.clipboard.readText()) + const envelope = JSON.parse(raw) + expect(envelope.mill).toBe(1) + expect(envelope.kind).toBe('context') + expect(envelope.schema.type).toBe('object') + expect(envelope.allowedActions).toContain('create-cards') + expect(envelope.items[0].title).toBe('Getting started') + expect(envelope.instructions).toContain('JSON code block') + await page.keyboard.press('Escape') + }) +}) + +test('a valid reply reviews with collisions unchecked, and accepting creates only the checked card', async ({ page }) => { + const freshTitle = 'ZzE2eBridgeCard' + await withClipboardLock(async () => { + const reply = JSON.stringify({ + mill: 1, kind: 'reply', action: 'create-cards', + items: [{ title: 'Getting started' }, { title: freshTitle, note: 'from the reply' }], + }) + await openPanelWithClipboard(page, reply) + + const review = page.getByTestId('quick-panel-reply-review') + await expect(review).toBeVisible() + const checkboxes = review.getByTestId('quick-panel-reply-card-checkbox') + await expect(checkboxes).toHaveCount(2) + await expect(checkboxes.nth(0)).not.toBeChecked() + await expect(checkboxes.nth(1)).toBeChecked() + await expect(review.getByText(/Already exists as/)).toBeVisible() + + const confirm = review.getByTestId('quick-panel-reply-confirm') + await expect(confirm).toContainText('Create 1 card') + await confirm.click() + await expect(review).toHaveCount(0) + }) + + // The accepted card exists; the declined collision stayed singular. + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + const created = noteCard(page, freshTitle) + await expect(created).toBeVisible() + await expect(noteCard(page, 'Getting started')).toHaveCount(1) + + // Cleanup (within-file discipline). + await openCard(page, created) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await deleteViaPageMenu(page, overlay) + await expect(created).toHaveCount(0) +}) + +test('an invalid reply names its failures and Copy corrected context re-emits the contract', async ({ page }) => { + await withClipboardLock(async () => { + const bad = JSON.stringify({ mill: 1, kind: 'reply', action: 'create-cards', items: [{ note: 'no title here' }] }) + await openPanelWithClipboard(page, bad) + + const invalid = page.getByTestId('quick-panel-reply-invalid') + await expect(invalid).toBeVisible() + await expect(invalid).toContainText('title') + + await invalid.getByTestId('quick-panel-reply-copy-correction').click() + await expect(invalid.getByTestId('quick-panel-reply-copy-correction')).toContainText('Copied') + const raw = await page.evaluate(() => navigator.clipboard.readText()) + const envelope = JSON.parse(raw) + expect(envelope.kind).toBe('context') + expect(envelope.instructions).toContain('did not validate') + expect(envelope.schema.properties.action.enum).toContain('create-cards') + }) +}) diff --git a/frontend/src/app/QuickPanel.tsx b/frontend/src/app/QuickPanel.tsx index 8bf30209..b9441c09 100644 --- a/frontend/src/app/QuickPanel.tsx +++ b/frontend/src/app/QuickPanel.tsx @@ -4,8 +4,7 @@ import { Events } from '@wailsio/runtime' import { Text } from '@primer/react' import { FilteredActionList } from '@primer/react/experimental' import { NoteIcon, PlayIcon } from '@primer/octicons-react' -import { AtlasService, CompositionService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings' -import type { ClipboardApplyPreview } from '../shared/bindings' +import { AtlasService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings' import { generateSamplePayload } from '../shared/configSchema' import { useAppStore, refreshWorkflows, refreshRequests, refreshKeybindings } from '../shared/store' import { @@ -19,6 +18,8 @@ import { buildConfigureAndActionEntries } from './quickPanelActionEntries' import type { PanelEntry } from './quickPanelActionEntries' import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture' import { QuickPanelClipboardApply } from './QuickPanelClipboardApply' +import { QuickPanelReplyReview } from './QuickPanelReplyReview' +import { useQuickPanelClipboardDoor } from './useQuickPanelClipboardDoor' import { FacetChipRow } from '../shared/FacetChipRow' import { useQuickPanelFacetSearch } from './quickPanelFacets' import styles from './QuickPanel.module.css' @@ -125,14 +126,6 @@ export function QuickPanel() { // those; duplicating them per-window would double-fire OS // notifications for the same pending item). const [reviewPendingCount, setReviewPendingCount] = useState(0) - // docs/goals/0039: non-null swaps the panel body from the search list - // into QuickPanelClipboardApply's preview-confirm view. json is the - // exact clipboard text the preview was computed from -- re-sent to - // ConfirmClipboardApply on confirm rather than re-read from the - // clipboard a second time (the user's gesture already captured it - // once; a second OS-level read has no reason to differ and would - // just be a second permission prompt). - const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null) const inputRef = useRef(null) // Declared before the effects that reference them (react-hooks/ @@ -318,32 +311,9 @@ export function QuickPanel() { }) } - // docs/goals/0039: reads the clipboard on the row's own click/Enter - // (the user gesture the Clipboard API requires) and hands the raw - // text to PreviewClipboardApply -- checked what exists first: the - // clipboard adapter (internal/adapters/clipboard) is wired for - // workflow-EXECUTION-side capture/apply nodes, not exposed as a - // general read-text RPC, and this window is an ordinary Wails webview - // where navigator.clipboard.readText() already works. Never throws - // through to the caller -- every failure path (permission denied, - // empty clipboard, malformed/unrecognized payload) becomes a - // Recognized=false preview so QuickPanelClipboardApply's own error - // view renders it, same as a genuinely bad payload would. - const applyFromClipboard = () => { - navigator.clipboard.readText() - .then((text) => { - if (!text.trim()) { - setClipboardApply({ json: text, preview: { recognized: false, error: t('quickPanel.clipboard.emptyError') } }) - return - } - CompositionService.PreviewClipboardApply(text) - .then((preview) => setClipboardApply({ json: text, preview })) - .catch((err) => setClipboardApply({ json: text, preview: { recognized: false, error: String(err) } })) - }) - .catch((err) => { - setClipboardApply({ json: '', preview: { recognized: false, error: t('quickPanel.clipboard.readError', { error: String(err) }) } }) - }) - } + // The clipboard door (goals 0039 + 0099) lives in its own hook -- + // one row recognizes both a workflow export and a mill reply. + const { clipboardApply, setClipboardApply, replyReview, setReplyReview, applyFromClipboard } = useQuickPanelClipboardDoor(t) // The away-capture door (docs/goals/0090): a typed query with no // intent to search becomes a Note instead, filed into the Scratchpad @@ -452,6 +422,22 @@ export function QuickPanel() { // (ADR-0033) has no room for a second, nested surface, so this is a // full replacement, not an overlay. Cancel/Applied both clear the // state, returning to the ordinary search list. + if (replyReview) { + return ( +
+ setReplyReview(null)} + onApplied={(label) => { + setReplyReview(null) + setStatus(t('quickPanel.status.replyApplied', { label })) + window.setTimeout(() => { void SettingsService.DismissPanel().catch(() => {}) }, 600) + }} + /> +
+ ) + } + if (clipboardApply) { return (
diff --git a/frontend/src/app/QuickPanelReplyReview.tsx b/frontend/src/app/QuickPanelReplyReview.tsx new file mode 100644 index 00000000..b21c3fca --- /dev/null +++ b/frontend/src/app/QuickPanelReplyReview.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Banner, Button, Checkbox, FormControl, Stack, Text } from '@primer/react' +import { ArrowLeftIcon, CopyIcon } from '@primer/octicons-react' +import { AtlasService, ExecutionService, RunKind } from '../shared/bindings' +import type { ClipbridgeReplyPreview } from '../shared/bindings' +import styles from './QuickPanelClipboardApply.module.css' + +// The clipboard bridge's review surface (goal 0099) -- the fourth +// instance of the preview-dialog language (folder import, list-row +// import, and the MCP approval plane are the siblings): every write +// renders as a checkbox row BEFORE anything executes, colliding rows +// default unchecked with their collision named, and the confirm button +// carries the count it will create. Nothing in a reply auto-runs -- +// create-class actions get this review, and replace/delete-class +// actions do not exist in the contract yet (clipbridge.MayAutoRun is +// the enforcement, not this component). +// +// The correction loop is re-ask-the-source: "Copy corrected context" +// re-emits the envelope (via AtlasService.CorrectionEnvelope) carrying +// what failed or what was declined, for a fresh reply from the same AI. + +interface Props { + preview: ClipbridgeReplyPreview + onCancel: () => void + onApplied: (label: string) => void +} + +export function QuickPanelReplyReview({ preview, onCancel, onApplied }: Props) { + const { t } = useTranslation('app') + const [busy, setBusy] = useState(false) + const [confirmError, setConfirmError] = useState(null) + const [copied, setCopied] = useState(false) + const cards = preview.Cards ?? [] + const notes = preview.NoteTexts ?? [] + const [accepted, setAccepted] = useState>( + () => new Set(cards.map((c, i) => (c.CollidesWithID ? -1 : i)).filter((i) => i >= 0)), + ) + + const copyCorrection = (problems: string[], declined: string[]) => { + AtlasService.CorrectionEnvelope(problems, declined) + .then((envelope) => navigator.clipboard.writeText(envelope)) + .then(() => setCopied(true)) + .catch((err) => setConfirmError(String(err))) + } + + if (!preview.Valid) { + return ( + + + + + + ) + } + + const isNoteRoute = notes.length > 0 + const acceptedCount = isNoteRoute ? notes.length : accepted.size + + const confirm = () => { + // Compute the accepted payload into a local value before anything + // async -- never round-tripped through state after a set call. + const items = isNoteRoute + ? notes.map((text) => ({ text })) + : cards.filter((_, i) => accepted.has(i)).map((c) => ({ + title: c.Draft.title, + ...(c.Draft.kind ? { kind: c.Draft.kind } : {}), + ...(c.Draft.note ? { note: c.Draft.note } : {}), + ...(c.Draft.summary ? { summary: c.Draft.summary } : {}), + })) + if (items.length === 0 || !preview.RouteWorkflowID) return + setBusy(true) + setConfirmError(null) + ExecutionService.RunWorkflow(preview.RouteWorkflowID, RunKind.RunKindTest, { items: JSON.stringify(items) }) + .then((summary) => { + if (summary.status !== 'SUCCESS') { + setBusy(false) + setConfirmError(t('quickPanelReplyReview.runFailed', { status: summary.status })) + return + } + onApplied(preview.RouteLabel ?? '') + }) + .catch((err) => { + setBusy(false) + setConfirmError(String(err)) + }) + } + + const declinedTitles = cards.filter((_, i) => !accepted.has(i)).map((c) => c.Draft.title) + + return ( + + {t('quickPanelReplyReview.title')} + {isNoteRoute ? ( + + {notes.map((text, i) => ( + {text} + ))} + + ) : ( + + {cards.map((offer, i) => ( + + { + setAccepted((prev) => { + const next = new Set(prev) + if (next.has(i)) { next.delete(i) } else { next.add(i) } + return next + }) + }} + data-testid="quick-panel-reply-card-checkbox" + /> + + {offer.Draft.title} + {offer.Draft.kind ? ` · ${offer.Draft.kind}` : ''} + + {offer.CollidesWithID ? ( + + {t('quickPanelReplyReview.collision', { kind: offer.CollidesWithKind || t('quickPanelReplyReview.collisionGenericKind') })} + + ) : offer.Draft.note ? ( + {offer.Draft.note} + ) : null} + + ))} + + )} + {confirmError && } + + + + + {declinedTitles.length > 0 && ( + + )} + + ) +} diff --git a/frontend/src/app/useQuickPanelClipboardDoor.ts b/frontend/src/app/useQuickPanelClipboardDoor.ts new file mode 100644 index 00000000..f512c1ff --- /dev/null +++ b/frontend/src/app/useQuickPanelClipboardDoor.ts @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { AtlasService, CompositionService } from '../shared/bindings' +import type { ClipboardApplyPreview, ClipbridgeReplyPreview } from '../shared/bindings' + +// The Quick Panel's ONE clipboard door (goals 0039 + 0099): the same +// row recognizes both payload families -- a workflow export (apply +// preview) first, then a mill reply envelope (the clipboard bridge's +// review surface). Reads the clipboard on the row's own click/Enter +// (the user gesture the Clipboard API requires). Never throws through +// to the caller: every failure path (permission denied, empty +// clipboard, malformed payload) becomes a Recognized=false apply +// preview so the error view renders it inline. +export function useQuickPanelClipboardDoor(t: (key: string, opts?: Record) => string) { + const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null) + const [replyReview, setReplyReview] = useState(null) + + const applyFromClipboard = () => { + navigator.clipboard.readText() + .then((text) => { + if (!text.trim()) { + setClipboardApply({ json: text, preview: { recognized: false, error: t('quickPanel.clipboard.emptyError') } }) + return + } + CompositionService.PreviewClipboardApply(text) + .then((preview) => { + if (preview.recognized) { + setClipboardApply({ json: text, preview }) + return + } + AtlasService.PreviewClipbridgeReply(text) + .then((reply) => { + if (reply.Recognized) { + setReplyReview(reply) + } else { + setClipboardApply({ json: text, preview }) + } + }) + .catch(() => setClipboardApply({ json: text, preview })) + }) + .catch((err) => setClipboardApply({ json: text, preview: { recognized: false, error: String(err) } })) + }) + .catch((err) => { + setClipboardApply({ json: '', preview: { recognized: false, error: t('quickPanel.clipboard.readError', { error: String(err) }) } }) + }) + } + + return { clipboardApply, setClipboardApply, replyReview, setReplyReview, applyFromClipboard } +} diff --git a/frontend/src/atlas/AtlasCardOverlay.tsx b/frontend/src/atlas/AtlasCardOverlay.tsx index c21c12bc..a9e13ade 100644 --- a/frontend/src/atlas/AtlasCardOverlay.tsx +++ b/frontend/src/atlas/AtlasCardOverlay.tsx @@ -86,6 +86,7 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl const [receiptStatus, setReceiptStatus] = useState(null) const [includeAttachments, setIncludeAttachments] = useState(false) const [copied, setCopied] = useState(false) + const [copiedAI, setCopiedAI] = useState(false) const [shareError, setShareError] = useState('') const shareActions = atlasCardShareActions(displayedCard, (message) => setShareError(message)) // D5 (goal 0081 slice A3): a file dropped while this page is open @@ -115,6 +116,12 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl setTimeout(() => setCopied(false), 1500) } + const copyForAI = async () => { + await shareActions.copyForAI() + setCopiedAI(true) + setTimeout(() => setCopiedAI(false), 1500) + } + useEffect(() => { if (!displayedCard.ReceiptRunID) { setReceiptStatus(null) @@ -295,6 +302,8 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl onIncludeAttachmentsChange={setIncludeAttachments} copied={copied} onCopyContext={() => void copyContext()} + copiedAI={copiedAI} + onCopyForAI={() => void copyForAI()} onCopyLink={() => void shareActions.copyCloudLink()} />
diff --git a/frontend/src/atlas/AtlasCardPageMetaRail.tsx b/frontend/src/atlas/AtlasCardPageMetaRail.tsx index cd6d9a34..6166e727 100644 --- a/frontend/src/atlas/AtlasCardPageMetaRail.tsx +++ b/frontend/src/atlas/AtlasCardPageMetaRail.tsx @@ -19,7 +19,7 @@ import styles from './AtlasCardPage.module.css' // matching the canvas card menu -- this row used to duplicate it. export function AtlasCardPageMetaRail({ card, updating, onUpdateNow, receiptStatus, onOpenRun, - includeAttachments, onIncludeAttachmentsChange, copied, onCopyContext, onCopyLink, + includeAttachments, onIncludeAttachmentsChange, copied, onCopyContext, copiedAI, onCopyForAI, onCopyLink, }: { card: Card updating: boolean @@ -30,6 +30,8 @@ export function AtlasCardPageMetaRail({ onIncludeAttachmentsChange: (checked: boolean) => void copied: boolean onCopyContext: () => void + copiedAI: boolean + onCopyForAI: () => void onCopyLink: () => void }) { const { t } = useTranslation('atlas') @@ -89,6 +91,9 @@ export function AtlasCardPageMetaRail({ {copied ? : } {copied ? t('overlay.copied') : t('overlay.copyAsContext')} + + {copiedAI ? : } {copiedAI ? t('overlay.copied') : t('overlay.copyForAI')} + {card.Source && ( {t('overlay.copyCloudLink')} diff --git a/frontend/src/atlas/atlasCardShare.ts b/frontend/src/atlas/atlasCardShare.ts index 3dd0a2f5..09e2ff51 100644 --- a/frontend/src/atlas/atlasCardShare.ts +++ b/frontend/src/atlas/atlasCardShare.ts @@ -20,6 +20,15 @@ export function atlasCardShareActions(card: Card, onError: (message: string) => } } + const copyForAI = async (): Promise => { + try { + const envelope = await AtlasService.CardContextEnvelope(card.ID) + await navigator.clipboard.writeText(envelope) + } catch (err) { + onError(String(err)) + } + } + const copyCloudLink = async (): Promise => { try { await navigator.clipboard.writeText(card.Source) @@ -36,5 +45,5 @@ export function atlasCardShareActions(card: Card, onError: (message: string) => } } - return { copyAsContext, copyCloudLink, revealFile } + return { copyAsContext, copyForAI, copyCloudLink, revealFile } } diff --git a/frontend/src/locales/en/app.json b/frontend/src/locales/en/app.json index 93c6c8a4..66856293 100644 --- a/frontend/src/locales/en/app.json +++ b/frontend/src/locales/en/app.json @@ -61,7 +61,8 @@ "failed": "\"{{label}}\" failed: {{error}}", "started": "Started \"{{label}}\"", "appliedUpdated": "Updated \"{{label}}\"", - "appliedCreated": "Created \"{{label}}\"" + "appliedCreated": "Created \"{{label}}\"", + "replyApplied": "Done — {{label}}" }, "clipboard": { "emptyError": "Clipboard is empty -- copy a workflow export first", @@ -183,5 +184,19 @@ "body": "{{count}} tab{{plural}} {{has}} unsaved changes.", "confirm": "Close tabs", "cancel": "Cancel" + }, + "quickPanelReplyReview": { + "title": "Review this reply before anything is created", + "invalidTitle": "This reply doesn't match what Mill asked for", + "invalidFallback": "The reply could not be validated.", + "copyCorrection": "Copy corrected context", + "correctionCopied": "Copied — paste it back to the AI", + "collision": "Already exists as {{kind}} — unchecked so nothing is overwritten", + "collisionGenericKind": "a card", + "confirmCards": "Create {{count}} cards", + "confirmCards_one": "Create 1 card", + "confirmNote": "Save to Scratchpad", + "errorTitle": "That didn't work", + "runFailed": "The workflow run ended with status {{status}}." } } diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index 3f836cdf..4dc568fc 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -169,7 +169,8 @@ "runStarted": "Started", "actionRunError": "Couldn't start the action. Try again.", "removeAction": "Remove action", - "actionsHint": "Each action receives this card's id, kind, and title." + "actionsHint": "Each action receives this card's id, kind, and title.", + "copyForAI": "Copy for AI" }, "page": { "close": "Close ⨯", @@ -234,4 +235,4 @@ "dissolveBody_other": "Its {{count}} cards move up a level.", "dissolveConfirm": "Dissolve" } -} \ No newline at end of file +} diff --git a/frontend/src/shared/bindings.ts b/frontend/src/shared/bindings.ts index e6127cd2..2ba34296 100644 --- a/frontend/src/shared/bindings.ts +++ b/frontend/src/shared/bindings.ts @@ -6,6 +6,10 @@ // (bindings/.../internal/domain/*, internal/adapters/*) are unaffected // by service moves and stay direct. export { AtlasService } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc' +export type { + ClipbridgeCardOffer, + ClipbridgeReplyPreview, +} from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc' export type { FolderImportSummary, FolderScanEntry, From 0dd4de9eb5fc4978e48be2dc247deb6f042bdc4c Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 08:27:39 -0400 Subject: [PATCH 4/5] fix: reply schema built structurally, never by string formatting (CodeQL: unsafe quoting) The two enum injections now parse the committed literal template and set the arrays as values -- label content is inert data and can never alter the document's shape. Regression test pins a hostile label carrying quotes/refs staying inside its enum string. Both scanner findings on schema.go resolve at the root rather than by suppression. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- internal/domain/clipbridge/clipbridge_test.go | 24 ++++++++ internal/domain/clipbridge/schema.go | 60 ++++++++++++------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/internal/domain/clipbridge/clipbridge_test.go b/internal/domain/clipbridge/clipbridge_test.go index 686960fa..2fffde8d 100644 --- a/internal/domain/clipbridge/clipbridge_test.go +++ b/internal/domain/clipbridge/clipbridge_test.go @@ -189,3 +189,27 @@ func TestEnvelopeAndReplyStayDistinguishable(t *testing.T) { t.Fatal("a context envelope must not validate as a reply") } } + +// Regression (CodeQL: unsafe quoting class): a kind label carrying +// quotes/JSON metacharacters must land as an inert enum VALUE -- it can +// never alter the schema document's structure, because the schema is +// built structurally, not by string formatting. +func TestReplySchema_HostileLabelsStayInert(t *testing.T) { + hostile := `x"],"$ref":"https://evil.example/schema"}//` + raw, err := ReplySchema([]string{hostile, "Topic"}, V1Actions()) + if err != nil { + t.Fatalf("ReplySchema: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("schema no longer parses: %v", err) + } + defs := doc["$defs"].(map[string]any)["item"].(map[string]any) + kindEnum := defs["properties"].(map[string]any)["kind"].(map[string]any)["enum"].([]any) + if len(kindEnum) != 2 || kindEnum[0] != hostile { + t.Fatalf("hostile label mangled or lost: %v", kindEnum) + } + if strings.Contains(string(raw), "evil.example\"}") { + t.Fatal("label content escaped its enum string") + } +} diff --git a/internal/domain/clipbridge/schema.go b/internal/domain/clipbridge/schema.go index 100a8401..9412a7b6 100644 --- a/internal/domain/clipbridge/schema.go +++ b/internal/domain/clipbridge/schema.go @@ -9,14 +9,15 @@ import ( // context envelope are distinguishable on the same clipboard. const KindReply = "reply" -// replySchemaTemplate is the committed literal reply contract. Two -// injection slots only -- the kind-label enum and the action enum -- -// both filled with json.Marshal-ed string arrays, so the keyword -// vocabulary can never widen at runtime. The vocabulary deliberately -// stays inside the portable structured-output intersection the goal -// contract locks: type/object/properties/required/ -// additionalProperties:false/enum/array items/local $refs -- no -// allOf/anyOf/if-then-else/external refs/recursion/format extensions +// replySchemaTemplate is the committed literal reply contract; the two +// empty enums (action, item kind) are the ONLY parts ReplySchema fills +// in, and it does so structurally -- parse, set the arrays, re-marshal +// -- never by string formatting, so no label content can alter the +// document's shape. The vocabulary deliberately stays inside the +// portable structured-output intersection the goal contract locks: +// type/object/properties/required/additionalProperties:false/enum/ +// array items/local $refs -- no allOf/anyOf/if-then-else/external +// refs/recursion/format extensions // (TestReplySchema_KeywordsStayInsidePortableSubset pins this). // Per-action item requirements (a card needs title, a note needs text) // are deliberately NOT expressed conditionally -- conditionals are @@ -29,7 +30,7 @@ const replySchemaTemplate = `{ "properties": { "mill": {"type": "integer", "enum": [1]}, "kind": {"type": "string", "enum": ["reply"]}, - "action": {"type": "string", "enum": %s}, + "action": {"type": "string", "enum": []}, "items": {"type": "array", "items": {"$ref": "#/$defs/item"}} }, "$defs": { @@ -38,7 +39,7 @@ const replySchemaTemplate = `{ "additionalProperties": false, "properties": { "title": {"type": "string"}, - "kind": {"type": "string", "enum": %s}, + "kind": {"type": "string", "enum": []}, "note": {"type": "string"}, "summary": {"type": "string"}, "text": {"type": "string"} @@ -53,18 +54,35 @@ func ReplySchema(kindLabels []string, actions []string) (json.RawMessage, error) if len(kindLabels) == 0 || len(actions) == 0 { return nil, fmt.Errorf("reply schema needs at least one kind label and one action") } - actionsJSON, err := json.Marshal(actions) - if err != nil { - return nil, fmt.Errorf("marshal actions enum: %w", err) + var doc map[string]any + if err := json.Unmarshal([]byte(replySchemaTemplate), &doc); err != nil { + return nil, fmt.Errorf("committed reply schema template is not valid JSON: %w", err) } - kindsJSON, err := json.Marshal(kindLabels) - if err != nil { - return nil, fmt.Errorf("marshal kinds enum: %w", err) + setEnum := func(path []string, values []string) error { + node := doc + for _, key := range path[:len(path)-1] { + next, ok := node[key].(map[string]any) + if !ok { + return fmt.Errorf("reply schema template lost its %q node", key) + } + node = next + } + enum := make([]any, len(values)) + for i, v := range values { + enum[i] = v + } + node[path[len(path)-1]] = map[string]any{"type": "string", "enum": enum} + return nil + } + if err := setEnum([]string{"properties", "action"}, actions); err != nil { + return nil, err } - doc := fmt.Sprintf(replySchemaTemplate, actionsJSON, kindsJSON) - var compact json.RawMessage - if err := json.Unmarshal([]byte(doc), &compact); err != nil { - return nil, fmt.Errorf("rendered reply schema is not valid JSON: %w", err) + if err := setEnum([]string{"$defs", "item", "properties", "kind"}, kindLabels); err != nil { + return nil, err + } + raw, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("marshal reply schema: %w", err) } - return json.RawMessage(doc), nil + return raw, nil } From 7c982d4c2ec7c13351f9e805dc3430d3edd66bd3 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Tue, 18 Aug 2026 09:12:31 -0400 Subject: [PATCH 5/5] test: palette census gains apply-atlas-from-reply (41 node types; advanced by its JSON items field) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- frontend/e2e/composition.spec.ts | 5 +++-- frontend/e2e/node-palette.spec.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/composition.spec.ts b/frontend/e2e/composition.spec.ts index e4c05182..f24e0ea9 100644 --- a/frontend/e2e/composition.spec.ts +++ b/frontend/e2e/composition.spec.ts @@ -92,8 +92,9 @@ test('Composition page lists built-in workflows; node primitives live in a colla // integration, goal 0066, ADR-0035/0038) + apply-backup-snapshot // (goal 0065's data-stewardship backup step) + apply-list-row (the // Lists write path, goal 0070) + apply-file-move (file verbs, goal - // 0087). - await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(40) + // 0087) + apply-atlas-from-reply (the clipboard bridge's accepted- + // reply materializer, goal 0099). + await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(41) }) test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => { diff --git a/frontend/e2e/node-palette.spec.ts b/frontend/e2e/node-palette.spec.ts index ad58c58f..152b5cda 100644 --- a/frontend/e2e/node-palette.spec.ts +++ b/frontend/e2e/node-palette.spec.ts @@ -97,7 +97,7 @@ test('palette search matches both the shortened display name and the full underl // RegisterNodeType call sites + the seeded "Check httpbin" declared // step type, goal 0054 slice A). await search.fill('') - await expect(panel.getByTestId('palette-item')).toHaveCount(40) + await expect(panel.getByTestId('palette-item')).toHaveCount(41) }) // Progressive-disclosure "Show advanced steps" toggle (goal 0047): the @@ -108,7 +108,7 @@ test('the palette shows every step by default, "Show advanced steps" checked', a await openPaletteOnNewWorkflow(page) const panel = activePanel(page) await expect(panel.getByTestId('palette-show-advanced')).toBeChecked() - await expect(panel.getByTestId('palette-item')).toHaveCount(40) + await expect(panel.getByTestId('palette-item')).toHaveCount(41) }) test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, and persists across a reload', async ({ page }) => { @@ -130,6 +130,8 @@ test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, a 'process-atlas-card-find', 'apply-atlas-card-create', 'apply-atlas-card-update', // goal 0070: fieldBindings is the same hand-authored JSON shape. 'apply-list-row', + // goal 0099: consumes a JSON items array from an attribute. + 'apply-atlas-from-reply', ] for (const id of advancedIDs) { await expect(panel.locator(`[data-node-type-id="${id}"]`)).toHaveCount(0)