diff --git a/docs/harness/README.md b/docs/harness/README.md index 771b380e..87c993f1 100644 --- a/docs/harness/README.md +++ b/docs/harness/README.md @@ -14,7 +14,9 @@ The user-facing command surface is intentionally small: - `setup`: install Agent Integration shim assets. - `local`: run or inspect Local Mnemon. - `status`: show Agent Integration, Local Mnemon, and Remote Workspace state. -- `sync`: connect Local Mnemon to a Remote Workspace. +- `sync`: connect Local Mnemon to a Remote Workspace. `mnemon-hub` is the + first-party backend; GitHub publication branches are available as an + experimental repo-mediated backend. Other implementation commands are internal and are not part of the beta product contract. @@ -29,6 +31,10 @@ The current beta does not promise production readiness, automatic apply, multi-agent governance, broad organization scope, or a general evaluation runtime. +The GitHub Remote Workspace backend is experimental. It uses explicitly +configured publication branches and does not implement P2P discovery, GitHub +Issues, GitHub PRs, or GitHub Actions as teamwork semantics. + ## 3. Separation From Stable Mnemon `mnemon-harness` is built from `./harness/cmd/mnemon-harness`. diff --git a/docs/harness/USAGE.md b/docs/harness/USAGE.md index ffed63c1..2b6dac44 100644 --- a/docs/harness/USAGE.md +++ b/docs/harness/USAGE.md @@ -38,12 +38,33 @@ Inspect local state: ## 3. Remote Workspace Sync -Connect a Remote Workspace: +Connect an HTTP `mnemon-hub` Remote Workspace: ```sh -./mnemon-harness sync connect my-workspace +./mnemon-harness sync connect my-workspace --remote-url https://mnemon-hub.example/sync --token-file ./hub.token ``` +Experimental GitHub publication backend: + +```sh +./mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ~/.config/mnemon/github.token + +./mnemon-harness sync connect agent-b \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ~/.config/mnemon/github.token +``` + +The GitHub backend is repo-mediated publication, not P2P discovery. Configure the +branches you publish and subscribe to explicitly. + Run one push or pull: ```sh diff --git a/harness/README.md b/harness/README.md index b5bf2d5c..8c96f716 100644 --- a/harness/README.md +++ b/harness/README.md @@ -8,8 +8,9 @@ The current product surface is intentionally small: - `setup` installs Agent Integration shim assets into Codex or Claude Code. - `local run` starts the project-local Mnemon service. - `status` reports Agent Integration, Local Mnemon, and sync status. -- `sync` connects Local Mnemon to a Remote Workspace (`mnemon-hub`) and pushes/pulls - governed commits with attribution preserved. +- `sync` connects Local Mnemon to a Remote Workspace and pushes/pulls governed + commits with attribution preserved. The first-party backend is `mnemon-hub`; + the experimental GitHub backend uses repo-mediated publication branches. - `loop validate` remains hidden and is used by `make harness-validate`. Host directories such as `.codex` and `.claude` are projection surfaces. Runtime @@ -48,3 +49,40 @@ Remove projected assets for a principal: ``` More command examples are in `docs/harness/USAGE.md`. + +## Experimental GitHub Remote Workspace + +GitHub can be used as a bootstrap Remote Workspace backend for a decentralized +publication mesh. Each Local Mnemon publishes accepted synced events to its own +configured branch and subscribes to explicitly configured peer branches. + +This is not P2P networking or GitHub Issues/PR-based teamwork. GitHub is only the +publication substrate; every imported event still enters through the receiving +Local Mnemon's Event Intake. + +Example shape: + +```sh +./mnemon-harness sync connect self \ + --backend github \ + --direction publish \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-a \ + --token-file ~/.config/mnemon/github.token + +./mnemon-harness sync connect agent-b \ + --backend github \ + --direction subscribe \ + --github-repo mnemon-dev/mnemon-teamwork-example \ + --github-branch mnemon/agent-b \ + --token-file ~/.config/mnemon/github.token +``` + +Live validation is opt-in: + +```sh +MNEMON_GITHUB_LIVE=1 \ +MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ +MNEMON_GITHUB_TOKEN_FILE=~/.config/mnemon/github.token \ +go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v +``` diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-harness/acceptance.go index b24357ff..3fe62871 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-harness/acceptance.go @@ -23,6 +23,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/state" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -147,8 +148,23 @@ type r1CodexAgentReport struct { type r1CodexSyncReport struct { Status string `json:"status"` + Backend string `json:"backend,omitempty"` + Repo string `json:"repo,omitempty"` + TransportModel string `json:"transport_model,omitempty"` + RosterSource string `json:"roster_source,omitempty"` + NetworkDiscovery string `json:"network_discovery,omitempty"` HubURL string `json:"hub_url"` + PublicationBranches []string `json:"publication_branches,omitempty"` + BranchByAgent map[string]string `json:"branch_by_agent,omitempty"` + RemotePlanPaths []string `json:"remote_plan_paths,omitempty"` + RuntimeWorkspaces []string `json:"runtime_workspace_paths,omitempty"` + LocalStorePaths []string `json:"local_mnemond_store_paths,omitempty"` + PublishedByBranch map[string]int `json:"published_events_by_branch,omitempty"` + ImportedByMnemond map[string]int `json:"imported_events_by_mnemond,omitempty"` + DiagnosticsByMnemond map[string]int `json:"diagnostics_by_mnemond,omitempty"` + ProfileByMnemond map[string]int `json:"profile_events_by_mnemond,omitempty"` AllowedEventSubjects []string `json:"allowed_event_subjects"` + Lifecycle []r1SyncLifecycleReport `json:"lifecycle,omitempty"` Source string `json:"source"` Target string `json:"target"` Agents []r1CodexAgentReport `json:"agents"` @@ -158,6 +174,16 @@ type r1CodexSyncReport struct { Artifacts map[string]string `json:"artifacts,omitempty"` } +type r1SyncLifecycleReport struct { + At string `json:"at"` + Principal string `json:"principal"` + Action string `json:"action"` + Result string `json:"result"` + Branch string `json:"branch,omitempty"` + Detail string `json:"detail,omitempty"` + Ledger map[string]int `json:"ledger,omitempty"` +} + type r1AcceptanceAssertion struct { Name string `json:"name"` Passed bool `json:"passed"` @@ -376,7 +402,11 @@ func installAcceptanceHarnessBinary(runRoot string) (string, error) { } func prepareR1AcceptanceRunRoot(runRoot string) error { - testdataRoot, err := filepath.Abs(".testdata") + testdataRoot, err := physicalAcceptancePath(".testdata") + if err != nil { + return err + } + runRoot, err = physicalAcceptancePath(runRoot) if err != nil { return err } @@ -401,6 +431,30 @@ func prepareR1AcceptanceRunRoot(runRoot string) error { return nil } +func physicalAcceptancePath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved, nil + } + var missing []string + for current := abs; ; current = filepath.Dir(current) { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return resolved, nil + } + parent := filepath.Dir(current) + if parent == current { + return abs, nil + } + missing = append(missing, filepath.Base(current)) + } +} + func setupR1CodexAgents(runRoot, binDir, controlURL string, count int, sourceCodexHome string) ([]r1CodexAgent, access.LoadedBindings, error) { var agents []r1CodexAgent var loaded access.LoadedBindings @@ -1081,7 +1135,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S if i-1 >= len(hub.Tokens) { return nil, fmt.Errorf("hub token missing for agent %d", i) } - if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", hub.URL, hub.Tokens[i-1], "", ""); err != nil { + if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, "", "", hub.Tokens[i-1], "", ""); err != nil { return nil, err } loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-harness/acceptance_github_mesh.go new file mode 100644 index 00000000..5f313645 --- /dev/null +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh.go @@ -0,0 +1,1528 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/app" + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" + "github.com/spf13/cobra" +) + +var ( + acceptanceGitHubRepo string + acceptanceGitHubTokenFile string + acceptanceGitHubBranchPrefix string + acceptanceGitHubScenarios []string + acceptanceGitHubSyncInterval time.Duration +) + +var acceptanceR1GitHubMeshCmd = &cobra.Command{ + Use: "r1-github-mesh-task-suite", + Short: "Run GitHub-backed Remote Workspace task-suite acceptance", + RunE: func(cmd *cobra.Command, args []string) error { + report, err := runR1GitHubMeshAcceptance(cmd.Context(), r1GitHubMeshAcceptanceOptions{ + r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ + RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, + Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }, + Repo: acceptanceGitHubRepo, + TokenFile: acceptanceGitHubTokenFile, + BranchPrefix: acceptanceGitHubBranchPrefix, + Scenarios: acceptanceGitHubScenarios, + SyncInterval: acceptanceGitHubSyncInterval, + }) + if report.ReportPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) + } + if err != nil { + return err + } + if report.Status != "ok" { + return fmt.Errorf("GitHub mesh task-suite acceptance status: %s", report.Status) + } + return nil + }, +} + +const r1GitHubMeshWorkerWakePrompt = `Check your Mnemon context. If there is governed work for you, act on it through +your own Local Mnemon and record durable progress. If your focus, availability, +or working state changed, update your agent_profile through your own Local Mnemon. +If there is no work for you, answer "no governed work".` + +func init() { + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") + acceptanceR1GitHubMeshCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") + acceptanceR1GitHubMeshCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed GitHub mesh events") + acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") + acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") + acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") + acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") + acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) +} + +type r1GitHubMeshAcceptanceOptions struct { + r1CodexAcceptanceOptions + Repo string + TokenFile string + BranchPrefix string + Scenarios []string + SyncInterval time.Duration +} + +func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceOptions) (r1CodexAcceptanceReport, error) { + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.Command == "" { + opts.Command = "codex" + } + if opts.Agents < 5 { + opts.Agents = 5 + } + if opts.TurnTimeout <= 0 { + opts.TurnTimeout = 5 * time.Minute + } + if opts.Repo == "" { + opts.Repo = "mnemon-dev/mnemon-teamwork-example" + } + if opts.SyncInterval <= 0 { + opts.SyncInterval = 30 * time.Second + } + started := time.Now().UTC().Truncate(time.Second) + branchPrefix := r1GitHubMeshBranchPrefix(opts.BranchPrefix, started) + runRoot := opts.RunRoot + if runRoot == "" { + runRoot = filepath.Join(".testdata", "r1-github-mesh-task-suite", started.Format("20060102T150405Z")) + } + runRoot, err := filepath.Abs(runRoot) + if err != nil { + return r1CodexAcceptanceReport{}, err + } + report := r1CodexAcceptanceReport{ + SchemaVersion: 1, + Status: "running", + StartedAt: started.Format(time.RFC3339), + RunRoot: runRoot, + Scenario: "github-mesh-task-suite", + AgentTurns: opts.AgentTurns, + LedgerCounts: map[string]int{}, + DerivedEventAudit: map[string]int{}, + Artifacts: map[string]string{}, + Raw: map[string]json.RawMessage{}, + RunnerContract: &r1RunnerContractReport{ + EntrypointProgressBeforeIntegration: -1, + EntrypointProgressAfterIntegration: -1, + WorkerWakePrompt: r1GitHubMeshWorkerWakePrompt, + }, + } + reportPath := filepath.Join(runRoot, "report.json") + report.ReportPath = reportPath + defer func() { + report.FinishedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) + _ = os.MkdirAll(filepath.Dir(reportPath), 0o755) + data, _ := json.MarshalIndent(report, "", " ") + _ = os.WriteFile(reportPath, append(data, '\n'), 0o644) + }() + if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + if opts.TokenFile == "" { + err := fmt.Errorf("--github-token-file is required") + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + tokenFile, err := filepath.Abs(opts.TokenFile) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + if _, err := os.Stat(tokenFile); err != nil { + err = fmt.Errorf("github token file: %w", err) + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) + if err := ensureR1GitHubMeshBranches(ctx, opts.Repo, tokenFile, branches); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + binDir, err := installAcceptanceHarnessBinary(runRoot) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["github_repo"] = opts.Repo + report.Artifacts["github_token_file"] = tokenFile + report.Artifacts["github_branch_prefix"] = branchPrefix + report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() + initialOnline := opts.Agents + if opts.AgentTurns { + initialOnline = r1GitHubMeshInitialOnline(opts.Agents) + } + report.Artifacts["github_initial_online_agents"] = fmt.Sprintf("%d", initialOnline) + + agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval, initialOnline) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + report.Topology.MnemonhubInstances = 0 + addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + + syncReport := buildR1GitHubMeshSyncReport(opts.Repo, agents) + report.Sync = syncReport + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + addR1Assertion(&report, "github-mesh no central mnemon-hub endpoint", syncReport.HubURL == "" && syncReport.HubStatus.HubEventsReceived == 0, "backend=github repo-mediated publication") + addR1Assertion(&report, "github-mesh no p2p node discovery", syncReport.NetworkDiscovery == "none" && syncReport.RosterSource == "configured-remotes-json", fmt.Sprintf("roster_source=%s network_discovery=%s", syncReport.RosterSource, syncReport.NetworkDiscovery)) + addR1Assertion(&report, "github-mesh publication branches configured", len(syncReport.PublicationBranches) == opts.Agents && len(syncReport.BranchByAgent) == opts.Agents, fmt.Sprintf("branches=%v", syncReport.PublicationBranches)) + addR1Assertion(&report, "github-mesh remote plans are per-workspace", distinctStrings(syncReport.RemotePlanPaths) && len(syncReport.RemotePlanPaths) == opts.Agents, fmt.Sprintf("remote_plans=%v", syncReport.RemotePlanPaths)) + addR1Assertion(&report, "github-mesh local stores isolated", distinctStrings(syncReport.LocalStorePaths) && len(syncReport.LocalStorePaths) == opts.Agents, fmt.Sprintf("stores=%v", syncReport.LocalStorePaths)) + addR1Assertion(&report, "github-mesh runtime workspaces isolated", distinctStrings(syncReport.RuntimeWorkspaces) && len(syncReport.RuntimeWorkspaces) == opts.Agents, fmt.Sprintf("workspaces=%v", syncReport.RuntimeWorkspaces)) + + if !opts.AgentTurns { + addR1Assertion(&report, "github-mesh real agent turns requested", false, "rerun with --agent-turns") + report.Status = "failed" + return report, fmt.Errorf("GitHub mesh task-suite acceptance requires --agent-turns") + } + for _, i := range r1GitHubMeshLocalOnlineIndexes(agents) { + if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + agentReport, raw, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + report.Agents = append(report.Agents, agentReport) + syncReport.Agents = append(syncReport.Agents, agentReport) + if raw != nil { + report.Raw[agents[i].principal+":hooks"] = raw + } + } + addR1Assertion(&report, "github-mesh initial appservers start/init", len(report.Agents) == initialOnline, fmt.Sprintf("started=%d initial_online=%d requested=%d", len(report.Agents), initialOnline, opts.Agents)) + + run := r1GitHubMeshRun{ + ctx: ctx, + opts: opts, + report: &report, + agents: agents, + runID: started.Format("150405"), + initialOnline: initialOnline, + } + if err := run.bootstrapProfiles(); err != nil { + addR1Error(&report, err) + } + for _, name := range r1GitHubMeshScenarioNames(opts.Scenarios) { + if err := run.runScenario(name); err != nil { + addR1Error(&report, err) + } + } + addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + addR1Assertion(&report, "github-mesh delayed mnemond join exercised", run.joined || initialOnline == opts.Agents, fmt.Sprintf("initial_online=%d total=%d lifecycle=%v", initialOnline, opts.Agents, syncReport.Lifecycle)) + addR1Assertion(&report, "github-mesh local mnemond leave/restart exercised", run.lifecycleExercised, fmt.Sprintf("lifecycle=%v", syncReport.Lifecycle)) + run.addPostScenarioAssertions() + obs, obsErr := observeAcceptanceRun(runRoot, 1000) + if obsErr == nil { + report.Observability = &obs + populateR1GitHubMeshSyncEvidence(&report, obs) + counts, warnings := r1GitHubMeshAuthoredEventCounts(agents) + if len(counts) == 0 { + counts = r1ClusterActorEventCounts(obs) + } + if len(warnings) > 0 { + report.Observability.Warnings = append(report.Observability.Warnings, warnings...) + } + report.Participants = r1ClusterParticipants(counts, report.Entrypoint) + } else { + addR1Error(&report, obsErr) + } + report.DerivedEventAudit = prodSimDerivedAudit(agents) + if len(agents) > 0 { + report.LedgerCounts = countR1Ledger(agents[0].localURL, agents[0].r1CodexAgent) + } + addR1Assertion(&report, "github-mesh no shared governed.db", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) + addR1Assertion(&report, "github-mesh report includes publication/import evidence", len(syncReport.PublishedByBranch) == opts.Agents && len(syncReport.ImportedByMnemond) == opts.Agents, fmt.Sprintf("published=%v imported=%v diagnostics=%v", syncReport.PublishedByBranch, syncReport.ImportedByMnemond, syncReport.DiagnosticsByMnemond)) + if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { + syncReport.Status = "ok" + report.Status = "ok" + return report, nil + } + syncReport.Status = "failed" + report.Status = "failed" + return report, fmt.Errorf("GitHub mesh natural task suite failed") +} + +type r1GitHubMeshRun struct { + ctx context.Context + opts r1GitHubMeshAcceptanceOptions + report *r1CodexAcceptanceReport + agents []r1CodexSyncAgent + runID string + initialOnline int + joined bool + lifecycleExercised bool +} + +func r1GitHubMeshScenarioNames(selected []string) []string { + if len(selected) == 0 { + return []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} + } + out := make([]string, 0, len(selected)) + for _, name := range selected { + name = strings.TrimSpace(name) + if name != "" { + out = append(out, name) + } + } + return out +} + +func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected []string) bool { + want := map[string]bool{} + for _, name := range r1GitHubMeshScenarioNames(selected) { + want[name] = false + } + for _, scenario := range scenarios { + if _, ok := want[scenario.Name]; ok && scenario.Status == "ok" { + want[scenario.Name] = true + } + } + if len(want) == 0 { + return false + } + for _, ok := range want { + if !ok { + return false + } + } + return true +} + +func r1GitHubMeshScenarioSelected(selected []string, name string) bool { + for _, selectedName := range selected { + if selectedName == name { + return true + } + } + return false +} + +func r1GitHubMeshOKScenarioNames(scenarios []r1TaskSimScenarioReport) []string { + out := []string{} + for _, scenario := range scenarios { + if scenario.Status == "ok" && strings.TrimSpace(scenario.Name) != "" { + out = append(out, scenario.Name) + } + } + sort.Strings(out) + return out +} + +func r1GitHubMeshCrossTaskReuseCandidate(name string, priorOK []string) bool { + if name != "sync-risk-review" { + return false + } + return r1GitHubMeshScenarioSelected(priorOK, "onboarding-synthesis") +} + +func r1GitHubMeshHasOKScenarioEvidenceBool(scenarios []r1TaskSimScenarioReport, name, key string) bool { + for _, scenario := range scenarios { + if scenario.Name != name || scenario.Status != "ok" { + continue + } + if value, ok := scenario.Evidence[key].(bool); ok && value { + return true + } + } + return false +} + +func r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios []r1TaskSimScenarioReport, key string, min int) bool { + for _, scenario := range scenarios { + if scenario.Status != "ok" { + continue + } + switch value := scenario.Evidence[key].(type) { + case int: + if value >= min { + return true + } + case float64: + if int(value) >= min { + return true + } + } + } + return false +} + +func (s *r1GitHubMeshRun) bootstrapProfiles() error { + profileCounts := map[string]int{} + active := r1GitHubMeshReadyAgentIndexes(s.agents) + if len(active) == 0 { + return fmt.Errorf("github mesh profile bootstrap requires at least one online agent") + } + for _, i := range active { + agent := &s.agents[i] + payload := taskSimJSON(map[string]any{ + "actor": agent.principal, + "focus": fmt.Sprintf("GitHub mesh Remote Workspace acceptance node %s", agent.principal), + "context_advantages": []string{"isolated local mnemond", "github publication branch sync", "real Codex appserver turn"}, + "availability": "available", + "ttl": "30m", + "summary": fmt.Sprintf("%s is available for GitHub mesh teamwork validation.", agent.principal), + }) + prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. +Use external id github-mesh-profile-%s-%s and payload: +%s +After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agent.principal), payload) + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "profile_bootstrap", prompt) + s.report.RunnerContract.ProfileBootstrapPrompts++ + answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh profile emitted "+agent.principal, false, err.Error()) + return err + } + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) + profileCounts[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)["agent_profile"] + } + allVisible := true + for _, i := range active { + agent := s.agents[i] + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(active), 120*time.Second) + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] + if counts["agent_profile"] < len(active) { + allVisible = false + } + } + addR1Assertion(s.report, "github-mesh initial profiles converge through publication branches", allVisible, fmt.Sprintf("initial_online_agents=%d", len(active))) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: "bootstrap_profiles", + Status: statusFromBool(allVisible), + Evidence: map[string]any{ + "profile_counts_by_agent": profileCounts, + "initial_online_agents": len(active), + "publication_branches": s.report.Sync.PublicationBranches, + }, + }) + if !allVisible { + return fmt.Errorf("profiles did not converge through GitHub publication branches") + } + return nil +} + +func (s *r1GitHubMeshRun) runScenario(name string) error { + entries, err := s.scenarioEntries(name) + if err != nil { + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: name, Status: "blocked", Evidence: map[string]any{"error": err.Error()}}) + return err + } + var actors []string + var entryTurns []*r1GitHubMeshEntryTurn + for _, entry := range entries { + agent := &s.agents[entry.index] + actors = append(actors, agent.principal) + s.report.RunnerContract.BusinessTaskPrompts++ + if s.report.Entrypoint == "" { + s.report.Entrypoint = agent.principal + s.report.Starter = agent.principal + s.report.Sync.Source = agent.principal + } + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "natural_user_message:"+name, entry.prompt) + turn, err := startR1GitHubMeshEntryTurn(agent, entry.index, entry.prompt, s.opts.TurnTimeout) + if err != nil { + addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, false, err.Error()) + return err + } + entryTurns = append(entryTurns, turn) + } + seedTimeout := r1GitHubMeshEntrySeedTimeout(s.opts.TurnTimeout) + for _, turn := range entryTurns { + agent := &s.agents[turn.index] + turn.counts, turn.seeded = waitR1GitHubMeshEntrySeed(agent, seedTimeout) + if res, ok := turn.poll(); ok { + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil && !turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + return res.Err + } + } + if !turn.seeded { + err := fmt.Errorf("%s did not publish governed seed events within %s", turn.principal, seedTimeout) + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", err, turn.counts)) + return err + } + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, true, fmt.Sprintf("seeded governed teamwork events counts=%v", turn.counts)) + } + if err := s.wakeWorkers(name, entries); err != nil { + return err + } + priorScenarios := r1GitHubMeshOKScenarioNames(s.report.Scenarios) + busyEntries := map[int]bool{} + for _, turn := range entryTurns { + if res, ok := turn.poll(); ok { + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" yielded governed seed before timeout", turn.seeded, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + if turn.seeded { + if err := s.restartAgentAppserver(turn.index, name, "entry turn timed out after publishing governed seed events"); err != nil { + return err + } + } + } + continue + } + busyEntries[turn.index] = true + } + leadIndex := r1GitHubMeshIntegrationAgentIndex(s.agents, entries, busyEntries) + if leadIndex < 0 { + return fmt.Errorf("github mesh scenario %s has no idle integration agent", name) + } + lead := &s.agents[leadIndex] + integrationPrompt := r1GitHubMeshIntegrationPrompt(name) + s.report.RunnerContract.IntegrationPrompts++ + recordR1ClusterPrompt(s.report.RunnerContract, lead.principal, "integration:"+name, integrationPrompt) + answer, err := runR1Turn(&lead.r1CodexAgent, integrationPrompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, lead.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh "+name+" integration", false, err.Error()) + return err + } + for _, turn := range entryTurns { + if turn.pollReady() { + res := turn.wait() + appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) + if res.Err != nil && turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" completed team handoff despite timeout", true, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) + } + continue + } + if turn.seeded { + addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" handed off while still running", true, fmt.Sprintf("counts=%v", turn.counts)) + if err := s.restartAgentAppserver(turn.index, name, "entry turn still running after team integration"); err != nil { + return err + } + } + } + waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 15*time.Second, 2*time.Second) + obs, err := observeAcceptanceRun(s.report.RunRoot, 1000) + if err != nil { + return err + } + counts, countWarnings := r1GitHubMeshAuthoredEventCounts(s.agents) + if len(counts) == 0 { + counts = r1ClusterActorEventCounts(obs) + } + participants := r1ClusterNonProfileParticipantCount(counts) + replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) + naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) + workerWakes := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "worker_wake:"+name) + integrationPrompts := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "integration:"+name) + assignments := r1GitHubMeshKindTotal(counts, "assignment") + progress := r1GitHubMeshKindTotal(counts, "progress_digest") + signals := r1GitHubMeshKindTotal(counts, "teamwork_signal") + intents := r1GitHubMeshKindTotal(counts, "project_intent") + passed := participants >= 2 && + replans >= 2 && + naturalMessages == len(entries) && + integrationPrompts >= 1 && + s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 && + assignments >= 1 && + progress >= 1 + addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d assignments=%d progress_digest=%d teamwork_signal=%d project_intent=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, assignments, progress, signals, intents, counts)) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: name, + Status: statusFromBool(passed), + Actors: actors, + Evidence: map[string]any{ + "participants": participants, + "replanning_rounds": replans, + "natural_user_messages": naturalMessages, + "worker_wake_prompts": workerWakes, + "integration_prompts": integrationPrompts, + "entry_poc_agents": actors, + "integration_agent": lead.principal, + "multi_poc": len(actors) > 1, + "prior_ok_scenarios": priorScenarios, + "cross_task_reuse_or_completion": r1GitHubMeshCrossTaskReuseCandidate(name, priorScenarios), + "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), + "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, + "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), + "cross_scenario_mnemon_ctx": true, + "actor_event_counts": counts, + "assignment_events": assignments, + "progress_digest_events": progress, + "teamwork_signal_events": signals, + "project_intent_events": intents, + }, + }) + if len(countWarnings) > 0 { + s.report.Scenarios[len(s.report.Scenarios)-1].Evidence["actor_event_count_warnings"] = countWarnings + } + if !passed { + return fmt.Errorf("github mesh scenario %s did not produce team-shaped multi-round evidence", name) + } + return nil +} + +func (s *r1GitHubMeshRun) addPostScenarioAssertions() { + profileCounts := r1GitHubMeshLedgerCountsByAgent(s.agents, "agent_profile") + baseline := len(s.agents) + if s.initialOnline > 0 && s.initialOnline < len(s.agents) { + baseline = s.initialOnline + } + refreshed := false + for _, count := range profileCounts { + if count > baseline { + refreshed = true + break + } + } + addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_online_agents=%d total_agents=%d", profileCounts, baseline, len(s.agents))) + if s.report.Sync != nil { + if s.report.Raw == nil { + s.report.Raw = map[string]json.RawMessage{} + } + raw, _ := json.Marshal(profileCounts) + s.report.Raw["github_mesh:profile_counts_after_scenarios"] = raw + } + selected := r1GitHubMeshScenarioNames(s.opts.Scenarios) + if r1GitHubMeshScenarioSelected(selected, "live-readiness-operator-safety") { + addR1Assertion(s.report, "github-mesh multi-poc scenario exercised", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "live-readiness-operator-safety", "multi_poc"), "scenario=live-readiness-operator-safety") + } + if r1GitHubMeshScenarioSelected(selected, "onboarding-synthesis") && r1GitHubMeshScenarioSelected(selected, "sync-risk-review") { + addR1Assertion(s.report, "github-mesh cross-task reuse/completion evidence recorded", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "sync-risk-review", "cross_task_reuse_or_completion"), "scenario=sync-risk-review prior=onboarding-synthesis") + } + addR1Assertion(s.report, "github-mesh output-driven replanning evidence recorded", r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(s.report.Scenarios, "replanning_rounds", 2), "requires at least one successful natural scenario with two or more prompt rounds") +} + +type r1GitHubMeshScenarioEntry struct { + index int + prompt string +} + +type r1GitHubMeshTurnResult struct { + Answer string + Err error +} + +type r1GitHubMeshEntryTurn struct { + index int + principal string + before int + done chan r1GitHubMeshTurnResult + result *r1GitHubMeshTurnResult + reported bool + seeded bool + counts map[string]int +} + +func startR1GitHubMeshEntryTurn(agent *r1CodexSyncAgent, index int, prompt string, timeout time.Duration) (*r1GitHubMeshEntryTurn, error) { + server := agent.server + before := server.NotificationCount() + if _, err := server.Request("turn/start", map[string]any{ + "threadId": agent.threadID, + "input": []map[string]any{{"type": "text", "text": prompt}}, + "cwd": agent.workspace, + "approvalPolicy": "never", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + }, 30*time.Second); err != nil { + return nil, fmt.Errorf("%s: turn/start: %w", agent.principal, err) + } + turn := &r1GitHubMeshEntryTurn{ + index: index, + principal: agent.principal, + before: before, + done: make(chan r1GitHubMeshTurnResult, 1), + } + go func() { + if _, err := server.WaitNotification("turn/completed", timeout, before); err != nil { + text := codexapp.CombinedText(server.NotificationsSince(before)) + turn.done <- r1GitHubMeshTurnResult{ + Answer: truncateR1Cluster(text, 2000), + Err: fmt.Errorf("%s: wait turn/completed: %w", agent.principal, err), + } + return + } + notifications := server.NotificationsSince(before) + answer := codexapp.FinalAnswer(notifications) + if answer == "" { + answer = codexapp.CombinedText(notifications) + } + turn.done <- r1GitHubMeshTurnResult{Answer: answer} + }() + return turn, nil +} + +func (t *r1GitHubMeshEntryTurn) poll() (r1GitHubMeshTurnResult, bool) { + if t == nil { + return r1GitHubMeshTurnResult{}, false + } + if t.result != nil { + return *t.result, true + } + select { + case res := <-t.done: + t.result = &res + return res, true + default: + return r1GitHubMeshTurnResult{}, false + } +} + +func (t *r1GitHubMeshEntryTurn) pollReady() bool { + _, ok := t.poll() + return ok +} + +func (t *r1GitHubMeshEntryTurn) wait() r1GitHubMeshTurnResult { + if res, ok := t.poll(); ok { + return res + } + res := <-t.done + t.result = &res + return res +} + +func appendR1GitHubMeshEntryTurnAnswer(report *r1CodexSyncReport, turn *r1GitHubMeshEntryTurn, res r1GitHubMeshTurnResult) { + if report == nil || turn == nil || turn.reported { + return + } + appendSyncAgentAnswer(report, turn.principal, res.Answer) + turn.reported = true +} + +func r1GitHubMeshEntrySeedTimeout(turnTimeout time.Duration) time.Duration { + if turnTimeout <= 0 { + return 5 * time.Minute + } + return turnTimeout +} + +func waitR1GitHubMeshEntrySeed(agent *r1CodexSyncAgent, timeout time.Duration) (map[string]int, bool) { + deadline := time.Now().Add(timeout) + var counts map[string]int + for time.Now().Before(deadline) { + counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) + if r1GitHubMeshEntrySeedReady(counts) { + return counts, true + } + time.Sleep(500 * time.Millisecond) + } + counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) + return counts, r1GitHubMeshEntrySeedReady(counts) +} + +func r1GitHubMeshEntrySeedReady(counts map[string]int) bool { + return counts["assignment"] >= 1 +} + +func r1GitHubMeshIntegrationAgentIndex(agents []r1CodexSyncAgent, entries []r1GitHubMeshScenarioEntry, busy map[int]bool) int { + if len(entries) > 0 { + idx := entries[0].index + if idx >= 0 && idx < len(agents) && !busy[idx] && r1GitHubMeshAgentReady(agents[idx]) { + return idx + } + } + for i := range agents { + if busy[i] || !r1GitHubMeshAgentReady(agents[i]) { + continue + } + return i + } + return -1 +} + +func (s *r1GitHubMeshRun) restartAgentAppserver(index int, scenario, reason string) error { + if index < 0 || index >= len(s.agents) { + return fmt.Errorf("github mesh restart index out of range: %d", index) + } + agent := &s.agents[index] + if agent.server != nil { + agent.server.Close() + agent.server = nil + } + if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) + return err + } + agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) + if err != nil { + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) + return err + } + if raw != nil && s.report.Raw != nil { + s.report.Raw[agent.principal+":hooks:restart:"+scenario] = raw + } + if s.report.Sync != nil { + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "appserver_restart_after_entry_handoff", + Result: "ready", + Branch: s.report.Sync.BranchByAgent[agent.principal], + Detail: reason, + }) + appendSyncAgentAnswer(s.report.Sync, agent.principal, "restarted thread "+agentReport.ThreadID+" after "+reason) + } + addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, true, reason) + return nil +} + +func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { + if len(s.agents) < 5 { + return nil, fmt.Errorf("github mesh natural scenarios require five agents") + } + switch name { + case "onboarding-synthesis": + return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。`}}, nil + case "sync-risk-review": + return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil + case "live-readiness-operator-safety": + return []r1GitHubMeshScenarioEntry{ + {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, + {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, + }, nil + default: + return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) + } +} + +func (s *r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { + entry := map[int]bool{} + for _, item := range entries { + entry[item.index] = true + } + for cycle := 1; cycle <= 3; cycle++ { + for i := range s.agents { + if entry[i] || !r1GitHubMeshAgentReady(s.agents[i]) { + continue + } + agent := &s.agents[i] + s.report.RunnerContract.WorkerWakePrompts++ + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1GitHubMeshWorkerWakePrompt) + answer, err := runR1Turn(&agent.r1CodexAgent, r1GitHubMeshWorkerWakePrompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + s.report.RunnerContract.WorkerWakeErrors = append(s.report.RunnerContract.WorkerWakeErrors, fmt.Sprintf("%s cycle %d %s: %v", name, cycle, agent.principal, err)) + } + } + waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 8*time.Second, 1*time.Second) + if cycle == 1 { + if err := s.joinDelayedAgents(name); err != nil { + return err + } + } + if cycle == 2 && !s.lifecycleExercised { + if err := exerciseR1GitHubMeshLifecycle(s.ctx, s.report, s.agents, s.opts.SyncInterval); err != nil { + return err + } + s.lifecycleExercised = true + } + if cycle >= 2 { + counts, _ := r1GitHubMeshAuthoredEventCounts(s.agents) + if r1GitHubMeshTeamEvidenceCountsReady(counts) { + return nil + } + } + } + return nil +} + +func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { + if s.joined { + return nil + } + var joined []string + profileCounts := map[string]int{} + for i := range s.agents { + if r1GitHubMeshAgentReady(s.agents[i]) { + continue + } + agent := &s.agents[i] + branch := "" + if s.report.Sync != nil { + branch = s.report.Sync.BranchByAgent[agent.principal] + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "delayed_join_start", + Result: "requested", + Branch: branch, + Detail: "start a preconfigured isolated Local Mnemon during the natural GitHub mesh task", + }) + } + if err := startR1GitHubMeshLocalMnemond(s.ctx, agent, s.opts.SyncInterval); err != nil { + addR1Assertion(s.report, "github-mesh delayed mnemond join "+agent.principal, false, err.Error()) + return err + } + if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { + addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) + return err + } + agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) + if err != nil { + addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) + return err + } + s.report.Agents = append(s.report.Agents, agentReport) + if s.report.Sync != nil { + s.report.Sync.Agents = append(s.report.Sync.Agents, agentReport) + } + if raw != nil { + s.report.Raw[agent.principal+":hooks"] = raw + } + if err := s.emitJoinedProfile(agent, scenario); err != nil { + return err + } + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] + joined = append(joined, agent.principal) + if s.report.Sync != nil { + s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: agent.principal, + Action: "delayed_join_ready", + Result: "ready", + Branch: branch, + Detail: "joined configured publication mesh, initialized appserver, and published fresh profile", + Ledger: counts, + }) + } + } + s.joined = true + if len(joined) == 0 { + return nil + } + allVisible := true + convergeTimeout := r1GitHubMeshProfileConvergenceTimeout(s.opts.SyncInterval) + for _, i := range r1GitHubMeshReadyAgentIndexes(s.agents) { + agent := s.agents[i] + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), convergeTimeout) + counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) + profileCounts[agent.principal] = counts["agent_profile"] + if counts["agent_profile"] < len(s.agents) { + allVisible = false + } + } + passed := len(joined) >= 2 && allVisible + addR1Assertion(s.report, "github-mesh two delayed mnemond join and import backlog", passed, fmt.Sprintf("joined=%v profile_counts=%v", joined, profileCounts)) + s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ + Name: "delayed_join_profiles", + Status: statusFromBool(passed), + Actors: joined, + Evidence: map[string]any{ + "scenario": scenario, + "joined_agents": joined, + "profile_counts_by_agent": profileCounts, + "publication_branches": s.report.Sync.PublicationBranches, + }, + }) + if !passed { + return fmt.Errorf("delayed GitHub mesh join did not converge profiles through publication branches") + } + return nil +} + +func r1GitHubMeshProfileConvergenceTimeout(syncInterval time.Duration) time.Duration { + if syncInterval <= 0 { + return 120 * time.Second + } + timeout := syncInterval*4 + 30*time.Second + if timeout < 120*time.Second { + return 120 * time.Second + } + if timeout > 6*time.Minute { + return 6 * time.Minute + } + return timeout +} + +func (s *r1GitHubMeshRun) emitJoinedProfile(agent *r1CodexSyncAgent, scenario string) error { + payload := taskSimJSON(map[string]any{ + "actor": agent.principal, + "focus": fmt.Sprintf("Joined GitHub mesh task %s with fresh local context", scenario), + "context_advantages": []string{"late join backlog import", "github publication branch sync", "isolated local mnemond"}, + "availability": "available", + "ttl": "30m", + "summary": fmt.Sprintf("%s joined during %s and can pick up governed work from imported context.", agent.principal, scenario), + }) + prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. +Use external id github-mesh-join-profile-%s-%s and payload: +%s +After the command succeeds, answer "joined profile written".`, s.runID, prodSafeID(agent.principal), payload) + recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "delayed_join_profile:"+scenario, prompt) + s.report.RunnerContract.ProfileBootstrapPrompts++ + answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) + appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) + if err != nil { + addR1Assertion(s.report, "github-mesh delayed profile emitted "+agent.principal, false, err.Error()) + return err + } + waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) + return nil +} + +func r1GitHubMeshIntegrationPrompt(name string) string { + return fmt.Sprintf(`Read your own Local Mnemon context and integrate the GitHub mesh teamwork scenario %q. +Use only governed Mnemon events as teammate evidence. If first-round output reveals gaps, emit a follow-up assignment before finalizing; otherwise emit a final progress_digest with participants, evidence, gaps, and next action. +Answer with the event-backed result.`, name) +} + +func r1GitHubMeshPromptRounds(contract *r1RunnerContractReport, scenario string) int { + if contract == nil { + return 0 + } + rounds := 0 + for _, prompt := range contract.PromptAudit { + if strings.Contains(prompt.Kind, scenario) { + rounds++ + } + } + return rounds +} + +func r1GitHubMeshPromptKindCount(contract *r1RunnerContractReport, kind string) int { + if contract == nil { + return 0 + } + count := 0 + for _, prompt := range contract.PromptAudit { + if prompt.Kind == kind { + count++ + } + } + return count +} + +func r1GitHubMeshKindTotal(counts map[string]map[string]int, kind string) int { + total := 0 + for _, byKind := range counts { + total += byKind[kind] + } + return total +} + +func r1GitHubMeshTeamEvidenceCountsReady(counts map[string]map[string]int) bool { + return r1ClusterNonProfileParticipantCount(counts) >= 2 && + r1GitHubMeshKindTotal(counts, "assignment") >= 1 && + r1GitHubMeshKindTotal(counts, "progress_digest") >= 1 +} + +func r1GitHubMeshAuthoredEventCounts(agents []r1CodexSyncAgent) (map[string]map[string]int, []string) { + out := map[string]map[string]int{} + var warnings []string + for _, agent := range agents { + path := prodSimMnemondPath(agent) + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)") + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s open authored event counts: %v", agent.principal, err)) + continue + } + func() { + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, ` +SELECT actor, resource_kind, COUNT(*) +FROM sync_events +WHERE actor <> '' +GROUP BY actor, resource_kind +ORDER BY actor, resource_kind`) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s query authored event counts: %v", agent.principal, err)) + return + } + defer rows.Close() + for rows.Next() { + var actor, kind string + var count int + if err := rows.Scan(&actor, &kind, &count); err != nil { + warnings = append(warnings, fmt.Sprintf("%s scan authored event counts: %v", agent.principal, err)) + return + } + if out[actor] == nil { + out[actor] = map[string]int{} + } + out[actor][kind] += count + } + if err := rows.Err(); err != nil { + warnings = append(warnings, fmt.Sprintf("%s read authored event counts: %v", agent.principal, err)) + } + }() + } + return out, warnings +} + +func r1GitHubMeshLedgerCountsByAgent(agents []r1CodexSyncAgent, kind string) map[string]int { + out := make(map[string]int, len(agents)) + for _, agent := range agents { + out[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)[kind] + } + return out +} + +func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { + out := make(map[string]string, len(agents)) + for _, agent := range agents { + if strings.TrimSpace(agent.threadID) != "" { + out[agent.principal] = agent.threadID + } + } + return out +} + +func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration, initialOnline int) ([]r1CodexSyncAgent, error) { + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } + if initialOnline < 0 || initialOnline > count { + initialOnline = count + } + var agents []r1CodexSyncAgent + branches := r1GitHubMeshBranches(branchPrefix, count) + for i := 1; i <= count; i++ { + principal := fmt.Sprintf("codex-%02d@project", i) + workspace := filepath.Join(runRoot, "workspaces", fmt.Sprintf("codex-%02d", i)) + codexHome := filepath.Join(runRoot, "codex-home", fmt.Sprintf("codex-%02d", i)) + if err := os.MkdirAll(workspace, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("# R1 GitHub mesh acceptance workspace\n"), 0o644); err != nil { + return nil, err + } + if err := prepareAcceptanceCodexHome(codexHome, workspace, sourceCodexHome); err != nil { + return nil, err + } + localAddr, err := freeLoopbackAddr() + if err != nil { + return nil, err + } + localURL := "http://" + localAddr + if _, err := app.New(workspace).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ + Host: "codex", + ControlURL: localURL, + Principal: principal, + ProjectRoot: workspace, + UseToken: true, + }); err != nil { + return nil, err + } + if err := writeR1GitHubMeshRemotes(workspace, repo, tokenFile, branches, i-1); err != nil { + return nil, err + } + loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) + if err != nil { + return nil, err + } + token, err := acceptanceTokenForPrincipal(loaded.Tokens, contract.ActorID(principal)) + if err != nil { + return nil, err + } + agent := r1CodexSyncAgent{ + r1CodexAgent: r1CodexAgent{ + principal: principal, + workspace: workspace, + codexHome: codexHome, + token: token, + env: acceptanceEnv(binDir, codexHome, runRoot), + }, + localURL: localURL, + replicaPrincipal: principal, + replicaToken: tokenFile, + renderAuditPath: filepath.Join(workspace, ".mnemon", "harness", "local", "render-audit.jsonl"), + } + if i <= initialOnline { + if err := startR1GitHubMeshLocalMnemond(ctx, &agent, syncInterval); err != nil { + return nil, err + } + } + agents = append(agents, agent) + } + return agents, nil +} + +func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent, syncInterval time.Duration) error { + if report == nil || report.Sync == nil || len(agents) < 5 { + return nil + } + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } + target := &agents[2] + branch := report.Sync.BranchByAgent[target.principal] + report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: target.principal, + Action: "pause_local_mnemond", + Result: "requested", + Branch: branch, + Detail: "cancel one isolated Local Mnemon before teamwork turns; appserver remains initialized", + }) + if target.localCancel == nil { + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, "target has no localCancel") + return fmt.Errorf("%s has no local mnemond cancel function", target.principal) + } + target.localCancel() + if target.localErr != nil { + stopTimeout := 45 * time.Second + select { + case <-target.localErr: + case <-time.After(stopTimeout): + addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop after "+stopTimeout.String()) + return fmt.Errorf("%s local mnemond did not stop within %s", target.principal, stopTimeout) + } + } + target.localCancel = nil + target.localErr = nil + if err := startR1GitHubMeshLocalMnemond(ctx, target, syncInterval); err != nil { + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) + return err + } + counts := countR1Ledger(target.localURL, target.r1CodexAgent) + report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ + At: time.Now().UTC().Format(time.RFC3339), + Principal: target.principal, + Action: "restart_local_mnemond", + Result: "ready", + Branch: branch, + Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", + Ledger: counts, + }) + addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) + return nil +} + +func startR1GitHubMeshLocalMnemond(ctx context.Context, agent *r1CodexSyncAgent, syncInterval time.Duration) error { + if agent == nil { + return fmt.Errorf("nil GitHub mesh agent") + } + if agent.localCancel != nil { + return nil + } + if syncInterval <= 0 { + syncInterval = 30 * time.Second + } + loaded, err := access.LoadBindingFile(agent.workspace, filepath.Join(agent.workspace, access.DefaultBindingFile)) + if err != nil { + return err + } + addr := strings.TrimPrefix(agent.localURL, "http://") + if strings.TrimSpace(addr) == "" { + return fmt.Errorf("%s has no local URL", agent.principal) + } + localCtx, cancel := context.WithCancel(ctx) + localErr := make(chan error, 1) + go func(workspace, addr string, loaded access.LoadedBindings) { + localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ + ProjectRoot: workspace, + SyncInterval: syncInterval, + }, io.Discard) + }(agent.workspace, addr, loaded) + agent.localCancel = cancel + agent.localErr = localErr + if err := waitR1LocalReady(ctx, agent.r1CodexAgent, agent.localURL, 10*time.Second); err != nil { + cancel() + agent.localCancel = nil + agent.localErr = nil + return err + } + return nil +} + +func r1GitHubMeshInitialOnline(count int) int { + if count <= 2 { + return count + } + if count <= 5 { + return count - 2 + } + return count - 2 +} + +func r1GitHubMeshAgentReady(agent r1CodexSyncAgent) bool { + return agent.localCancel != nil && agent.server != nil && strings.TrimSpace(agent.threadID) != "" +} + +func r1GitHubMeshLocalOnlineIndexes(agents []r1CodexSyncAgent) []int { + out := []int{} + for i, agent := range agents { + if agent.localCancel != nil { + out = append(out, i) + } + } + return out +} + +func r1GitHubMeshReadyAgentIndexes(agents []r1CodexSyncAgent) []int { + out := []int{} + for i, agent := range agents { + if r1GitHubMeshAgentReady(agent) { + out = append(out, i) + } + } + return out +} + +func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { + if self < 0 || self >= len(branches) { + return fmt.Errorf("self index %d outside branches", self) + } + repo, err := exchange.NormalizeGitHubRepo(repo) + if err != nil { + return err + } + if strings.TrimSpace(tokenFile) == "" { + return fmt.Errorf("github token file is required") + } + normalized := make([]string, 0, len(branches)) + for _, branch := range branches { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return err + } + normalized = append(normalized, branch) + } + branches = normalized + remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") + if err := upsertSyncRemote(remotesPath, workspace, "self", exchange.RemoteBackendGitHub, exchange.RemoteDirectionPublish, "", repo, branches[self], "", tokenFile, ""); err != nil { + return err + } + for i, branch := range branches { + if i == self { + continue + } + id := fmt.Sprintf("stream-%02d", i+1) + if err := upsertSyncRemote(remotesPath, workspace, id, exchange.RemoteBackendGitHub, exchange.RemoteDirectionSubscribe, "", repo, branch, "", tokenFile, ""); err != nil { + return err + } + } + return nil +} + +func r1GitHubMeshBranches(prefix string, count int) []string { + if prefix == "" { + prefix = "mnemon/mnemond-" + } + out := make([]string, 0, count) + for i := 0; i < count; i++ { + suffix := fmt.Sprintf("%02d", i+1) + if strings.HasSuffix(prefix, "-") && i < 26 { + suffix = string(rune('a' + i)) + } + out = append(out, prefix+suffix) + } + return out +} + +func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { + prefix = strings.TrimSpace(prefix) + if prefix != "" { + return prefix + } + return "mnemon/mnemond-" + started.UTC().Format("20060102T150405Z") + "-" +} + +func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { + token, err := readR1GitHubMeshToken(tokenFile) + if err != nil { + return err + } + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: repo, + Token: token, + MutativeDelay: time.Second, + }) + if err != nil { + return err + } + if err := store.EnsureBranches(ctx, branches, "main"); err != nil { + return fmt.Errorf("ensure GitHub branches: %w", err) + } + return nil +} + +func readR1GitHubMeshToken(tokenFile string) (string, error) { + body, err := os.ReadFile(tokenFile) + if err != nil { + return "", fmt.Errorf("read github token file: %w", err) + } + token := strings.TrimSpace(string(body)) + if token == "" { + return "", fmt.Errorf("github token file is empty") + } + return token, nil +} + +func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1CodexSyncReport { + report := &r1CodexSyncReport{ + Status: "running", + Backend: exchange.RemoteBackendGitHub, + Repo: repo, + TransportModel: "repo-mediated-publication", + RosterSource: "configured-remotes-json", + NetworkDiscovery: "none", + AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), + Artifacts: map[string]string{}, + BranchByAgent: map[string]string{}, + PublishedByBranch: map[string]int{}, + ImportedByMnemond: map[string]int{}, + DiagnosticsByMnemond: map[string]int{}, + ProfileByMnemond: map[string]int{}, + } + for i, agent := range agents { + branch := "" + if remote, err := exchange.LoadRemoteEntry(filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json"), "self"); err == nil { + branch = remote.Branch + } else if agent.replicaPrincipal != "" { + branch = agent.replicaPrincipal + } + if branch != "" { + report.PublicationBranches = append(report.PublicationBranches, branch) + report.BranchByAgent[agent.principal] = branch + } + report.RuntimeWorkspaces = append(report.RuntimeWorkspaces, agent.workspace) + report.LocalStorePaths = append(report.LocalStorePaths, filepath.Join(agent.workspace, runtime.DefaultStorePath)) + remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + report.RemotePlanPaths = append(report.RemotePlanPaths, remotesPath) + report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = remotesPath + if i == 0 { + report.Source = agent.principal + } + } + sort.Strings(report.PublicationBranches) + sort.Strings(report.RemotePlanPaths) + return report +} + +func populateR1GitHubMeshSyncEvidence(report *r1CodexAcceptanceReport, obs acceptanceObserveReport) { + if report == nil || report.Sync == nil { + return + } + syncReport := report.Sync + if syncReport.PublishedByBranch == nil { + syncReport.PublishedByBranch = map[string]int{} + } + if syncReport.ImportedByMnemond == nil { + syncReport.ImportedByMnemond = map[string]int{} + } + if syncReport.DiagnosticsByMnemond == nil { + syncReport.DiagnosticsByMnemond = map[string]int{} + } + if syncReport.ProfileByMnemond == nil { + syncReport.ProfileByMnemond = map[string]int{} + } + stores := map[string]acceptanceStoreInspect{} + for _, store := range obs.Stores { + if store.Role == "mnemond" { + stores[store.Name] = store + } + } + for _, agent := range syncReport.Agents { + principal := strings.TrimSpace(agent.Principal) + if principal == "" { + continue + } + storeName := r1GitHubMeshStoreName(principal) + store, ok := stores[storeName] + if !ok { + continue + } + branch := syncReport.BranchByAgent[principal] + if branch != "" { + syncReport.PublishedByBranch[branch] = store.SyncEventsByStatus["synced"] + } + syncReport.ImportedByMnemond[principal] = store.Counts["imported_accepted"] + syncReport.DiagnosticsByMnemond[principal] = store.ObservedByType["sync.diagnostic"] + store.ObservedByType["sync.remote_diagnostic.observed"] + syncReport.ProfileByMnemond[principal] = store.EnvelopeByType["agent_profile.accepted"] + } +} + +func r1GitHubMeshStoreName(principal string) string { + name, _, _ := strings.Cut(strings.TrimSpace(principal), "@") + return name +} + +func r1GitHubMeshStrictTopology(top *r1AcceptanceTopologyReport) bool { + if top == nil || top.Mode != "per-hostagent-mnemond" || top.SharedMnemond || top.MnemonhubInstances != 0 || top.Agents < 5 || top.MnemondInstances != top.Agents { + return false + } + seen := map[string]bool{} + for _, path := range top.AgentMnemondMap { + if strings.TrimSpace(path) == "" || seen[path] { + return false + } + seen[path] = true + } + return len(seen) == top.Agents +} + +func r1GitHubMeshScopes() []contract.ResourceRef { + return []contract.ResourceRef{ + {Kind: "agent_profile", ID: "project"}, + {Kind: "project_intent", ID: "project"}, + {Kind: "teamwork_signal", ID: "project"}, + {Kind: "assignment", ID: "project"}, + {Kind: "progress_digest", ID: "project"}, + } +} + +func distinctStrings(values []string) bool { + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return false + } + seen[value] = true + } + return true +} diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go new file mode 100644 index 00000000..da79fb1a --- /dev/null +++ b/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go @@ -0,0 +1,470 @@ +package main + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { + got := r1GitHubMeshBranches("mnemon/mnemond-", 5) + want := []string{"mnemon/mnemond-a", "mnemon/mnemond-b", "mnemon/mnemond-c", "mnemon/mnemond-d", "mnemon/mnemond-e"} + if len(got) != len(want) { + t.Fatalf("branches = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("branches = %v, want %v", got, want) + } + } +} + +func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { + started := time.Date(2026, 6, 25, 18, 57, 20, 0, time.UTC) + prefix := r1GitHubMeshBranchPrefix("", started) + if prefix != "mnemon/mnemond-20260625T185720Z-" { + t.Fatalf("prefix = %q, want run-scoped mnemond prefix", prefix) + } + got := r1GitHubMeshBranches(prefix, 2) + want := []string{ + "mnemon/mnemond-20260625T185720Z-a", + "mnemon/mnemond-20260625T185720Z-b", + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("branches = %v, want %v", got, want) + } + } + if explicit := r1GitHubMeshBranchPrefix("mnemon/mnemond-team-", started); explicit != "mnemon/mnemond-team-" { + t.Fatalf("explicit prefix = %q, want unchanged", explicit) + } +} + +func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(root, "workspaces", "codex-03") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + branches := r1GitHubMeshBranches("mnemon/mnemond-", 5) + if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, 2); err != nil { + t.Fatalf("write github mesh remotes: %v", err) + } + + remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") + if err != nil { + t.Fatalf("load remote plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/mnemond-c" { + t.Fatalf("push targets = %+v, want self on mnemon/mnemond-c", plan.PushTargets) + } + if len(plan.PullSources) != 4 { + t.Fatalf("pull sources = %+v, want four peer streams", plan.PullSources) + } + for _, remote := range append(plan.PushTargets, plan.PullSources...) { + if remote.Backend != exchange.RemoteBackendGitHub || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || + remote.CredentialRef != tokenFile || + remote.Endpoint != "" { + t.Fatalf("remote not a github publication stream: %+v", remote) + } + } +} + +func TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/mnemond-", 5, "", 30*time.Second, 0) + if err != nil { + t.Fatalf("setup delayed github mesh agents: %v", err) + } + if len(agents) != 5 { + t.Fatalf("agents = %d, want 5", len(agents)) + } + if got := r1GitHubMeshLocalOnlineIndexes(agents); len(got) != 0 { + t.Fatalf("local online indexes = %v, want none before delayed start", got) + } + for i, agent := range agents { + if agent.localCancel != nil || agent.localErr != nil { + t.Fatalf("agent %d local mnemond should not be started", i) + } + remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") + if err != nil { + t.Fatalf("load delayed remote plan %d: %v", i, err) + } + if len(plan.PushTargets) != 1 || len(plan.PullSources) != 4 { + t.Fatalf("remote plan %d = %+v, want one publish and four subscribe streams", i, plan) + } + } +} + +func TestR1GitHubMeshInitialOnlineLeavesTwoDelayedAgents(t *testing.T) { + if got := r1GitHubMeshInitialOnline(5); got != 3 { + t.Fatalf("initial online for 5 = %d, want 3", got) + } + if got := r1GitHubMeshInitialOnline(7); got != 5 { + t.Fatalf("initial online for 7 = %d, want 5", got) + } + agents := []r1CodexSyncAgent{ + {localCancel: func() {}}, + {}, + {localCancel: func() {}}, + } + got := r1GitHubMeshLocalOnlineIndexes(agents) + if len(got) != 2 || got[0] != 0 || got[1] != 2 { + t.Fatalf("local online indexes = %v, want [0 2]", got) + } +} + +func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { + root := t.TempDir() + tokenFile := filepath.Join(root, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + branches := r1GitHubMeshBranches("mnemon/mnemond-", 3) + agents := make([]r1CodexSyncAgent, 0, 3) + for i := range branches { + workspace := filepath.Join(root, "workspaces", branches[i][len("mnemon/"):]) + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, i); err != nil { + t.Fatal(err) + } + agents = append(agents, r1CodexSyncAgent{r1CodexAgent: r1CodexAgent{ + principal: "codex-0" + string(rune('1'+i)) + "@project", + workspace: workspace, + }}) + } + + report := buildR1GitHubMeshSyncReport("mnemon-dev/mnemon-teamwork-example", agents) + if report.Backend != exchange.RemoteBackendGitHub || report.Repo != "mnemon-dev/mnemon-teamwork-example" || report.HubURL != "" { + t.Fatalf("report backend/repo/hub wrong: %+v", report) + } + if report.TransportModel != "repo-mediated-publication" || report.RosterSource != "configured-remotes-json" || report.NetworkDiscovery != "none" { + t.Fatalf("report must pin github bootstrap semantics without p2p discovery: %+v", report) + } + if len(report.PublicationBranches) != 3 || len(report.BranchByAgent) != 3 { + t.Fatalf("report branches wrong: %+v", report) + } + if len(report.RemotePlanPaths) != 3 || !distinctStrings(report.RemotePlanPaths) { + t.Fatalf("report must expose one remotes.json per workspace, got %+v", report.RemotePlanPaths) + } + if !distinctStrings(report.RuntimeWorkspaces) || !distinctStrings(report.LocalStorePaths) { + t.Fatalf("report must prove isolated workspaces/stores: workspaces=%v stores=%v", report.RuntimeWorkspaces, report.LocalStorePaths) + } + for i, storePath := range report.LocalStorePaths { + want := filepath.Join(agents[i].workspace, runtime.DefaultStorePath) + if storePath != want { + t.Fatalf("store path[%d] = %q, want %q", i, storePath, want) + } + } +} + +func TestR1GitHubMeshStrictTopologyRequiresNoHub(t *testing.T) { + top := &r1AcceptanceTopologyReport{ + Mode: "per-hostagent-mnemond", + Agents: 5, + MnemondInstances: 5, + MnemonhubInstances: 0, + SharedMnemond: false, + AgentMnemondMap: map[string]string{ + "a": "/tmp/a.db", + "b": "/tmp/b.db", + "c": "/tmp/c.db", + "d": "/tmp/d.db", + "e": "/tmp/e.db", + }, + } + if !r1GitHubMeshStrictTopology(top) { + t.Fatalf("github mesh topology should pass without a central hub: %+v", top) + } + top.MnemonhubInstances = 1 + if r1GitHubMeshStrictTopology(top) { + t.Fatal("github mesh topology must reject central mnemon-hub instances") + } +} + +func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { + report := &r1CodexAcceptanceReport{Sync: &r1CodexSyncReport{ + Agents: []r1CodexAgentReport{ + {Principal: "codex-01@project"}, + {Principal: "codex-02@project"}, + }, + BranchByAgent: map[string]string{ + "codex-01@project": "mnemon/mnemond-run-a", + "codex-02@project": "mnemon/mnemond-run-b", + }, + }} + obs := acceptanceObserveReport{Stores: []acceptanceStoreInspect{ + { + Name: "codex-01", + Role: "mnemond", + Counts: map[string]int{"imported_accepted": 4}, + SyncEventsByStatus: map[string]int{"synced": 3}, + ObservedByType: map[string]int{"sync.diagnostic": 1}, + EnvelopeByType: map[string]int{"agent_profile.accepted": 5}, + }, + { + Name: "codex-02", + Role: "mnemond", + Counts: map[string]int{"imported_accepted": 2}, + SyncEventsByStatus: map[string]int{"synced": 1}, + ObservedByType: map[string]int{"sync.remote_diagnostic.observed": 2}, + EnvelopeByType: map[string]int{"agent_profile.accepted": 3}, + }, + }} + + populateR1GitHubMeshSyncEvidence(report, obs) + + if got := report.Sync.PublishedByBranch["mnemon/mnemond-run-a"]; got != 3 { + t.Fatalf("published branch a = %d, want 3", got) + } + if got := report.Sync.ImportedByMnemond["codex-02@project"]; got != 2 { + t.Fatalf("imported codex-02 = %d, want 2", got) + } + if got := report.Sync.DiagnosticsByMnemond["codex-02@project"]; got != 2 { + t.Fatalf("diagnostics codex-02 = %d, want 2", got) + } + if got := report.Sync.ProfileByMnemond["codex-01@project"]; got != 5 { + t.Fatalf("profiles codex-01 = %d, want 5", got) + } +} + +func TestR1GitHubMeshScenarioContract(t *testing.T) { + names := r1GitHubMeshScenarioNames(nil) + want := []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} + if len(names) != len(want) { + t.Fatalf("default scenarios = %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Fatalf("default scenarios = %v, want %v", names, want) + } + } + run := r1GitHubMeshRun{agents: make([]r1CodexSyncAgent, 5)} + entries, err := run.scenarioEntries("live-readiness-operator-safety") + if err != nil { + t.Fatalf("multi-poc scenario entries: %v", err) + } + if len(entries) != 2 || entries[0].index == entries[1].index { + t.Fatalf("live-readiness scenario must use two distinct PoC agents, got %+v", entries) + } + for _, entry := range entries { + for _, forbidden := range []string{"Agent A must", "Agent B must", "must create assignments for"} { + if strings.Contains(entry.prompt, forbidden) { + t.Fatalf("natural prompt contains forced choreography %q: %s", forbidden, entry.prompt) + } + } + } + if !strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile") { + t.Fatal("github mesh worker wake prompt should let agents naturally refresh their profile") + } +} + +func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { + scenarios := []r1TaskSimScenarioReport{ + {Name: "onboarding-synthesis", Status: "ok"}, + {Name: "sync-risk-review", Status: "failed"}, + {Name: "live-readiness-operator-safety", Status: "ok"}, + } + if allR1GitHubMeshScenariosOK(scenarios, nil) { + t.Fatal("default scenario set must require every selected scenario to be ok") + } + scenarios[1].Status = "ok" + if !allR1GitHubMeshScenariosOK(scenarios, nil) { + t.Fatal("default scenario set should pass when every default scenario is ok") + } + if !allR1GitHubMeshScenariosOK(scenarios[:1], []string{"onboarding-synthesis"}) { + t.Fatal("explicit scenario selection should require only the selected scenario") + } +} + +func TestR1GitHubMeshNaturalSuiteEvidenceHelpers(t *testing.T) { + scenarios := []r1TaskSimScenarioReport{ + {Name: "onboarding-synthesis", Status: "ok", Evidence: map[string]any{"replanning_rounds": 3}}, + {Name: "sync-risk-review", Status: "ok", Evidence: map[string]any{"cross_task_reuse_or_completion": true}}, + {Name: "live-readiness-operator-safety", Status: "ok", Evidence: map[string]any{"multi_poc": true}}, + } + prior := r1GitHubMeshOKScenarioNames(scenarios[:1]) + if !r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", prior) { + t.Fatal("sync-risk-review should be a cross-task reuse candidate after onboarding-synthesis") + } + if r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", nil) { + t.Fatal("sync-risk-review without prior onboarding should not claim cross-task reuse") + } + if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { + t.Fatal("multi-poc evidence should be detected on the successful live-readiness scenario") + } + if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "sync-risk-review", "cross_task_reuse_or_completion") { + t.Fatal("cross-task evidence should be detected on the successful sync-risk scenario") + } + if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { + t.Fatal("replanning evidence should accept int counts") + } + scenarios[0].Evidence["replanning_rounds"] = float64(2) + if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { + t.Fatal("replanning evidence should accept json-decoded float64 counts") + } + scenarios[2].Status = "failed" + if r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { + t.Fatal("failed scenarios must not satisfy evidence assertions") + } +} + +func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { + contract := &r1RunnerContractReport{} + recordR1ClusterPrompt(contract, "codex-01@project", "natural_user_message:onboarding-synthesis", "prompt") + recordR1ClusterPrompt(contract, "codex-02@project", "worker_wake:onboarding-synthesis", "wake") + recordR1ClusterPrompt(contract, "codex-01@project", "integration:onboarding-synthesis", "integrate") + recordR1ClusterPrompt(contract, "codex-01@project", "integration:other", "other") + if got := r1GitHubMeshPromptRounds(contract, "onboarding-synthesis"); got != 3 { + t.Fatalf("prompt rounds = %d, want 3", got) + } + if got := r1GitHubMeshPromptKindCount(contract, "worker_wake:onboarding-synthesis"); got != 1 { + t.Fatalf("worker wake prompt count = %d, want 1", got) + } +} + +func TestR1GitHubMeshEntrySeedTimeoutIsBounded(t *testing.T) { + if got := r1GitHubMeshEntrySeedTimeout(8 * time.Minute); got != 8*time.Minute { + t.Fatalf("seed timeout = %s, want full turn timeout", got) + } + if got := r1GitHubMeshEntrySeedTimeout(2 * time.Minute); got != 2*time.Minute { + t.Fatalf("short seed timeout = %s, want full short turn timeout", got) + } + if got := r1GitHubMeshEntrySeedTimeout(0); got != 5*time.Minute { + t.Fatalf("default seed timeout = %s, want 5m", got) + } +} + +func TestR1GitHubMeshEntrySeedReadyUsesAssignment(t *testing.T) { + if !r1GitHubMeshEntrySeedReady(map[string]int{"assignment": 1}) { + t.Fatal("assignment should be enough to seed worker handoff") + } + if r1GitHubMeshEntrySeedReady(map[string]int{"teamwork_signal": 1}) { + t.Fatal("signal without assignment should not seed worker handoff") + } +} + +func TestR1GitHubMeshProfileConvergenceTimeoutScalesWithSyncInterval(t *testing.T) { + if got := r1GitHubMeshProfileConvergenceTimeout(30 * time.Second); got != 150*time.Second { + t.Fatalf("30s sync convergence timeout = %s, want 150s", got) + } + if got := r1GitHubMeshProfileConvergenceTimeout(90 * time.Second); got != 6*time.Minute { + t.Fatalf("90s sync convergence timeout = %s, want capped 6m", got) + } + if got := r1GitHubMeshProfileConvergenceTimeout(0); got != 120*time.Second { + t.Fatalf("default convergence timeout = %s, want 120s", got) + } +} + +func TestR1GitHubMeshIntegrationAgentSkipsBusyEntrypoint(t *testing.T) { + agents := []r1CodexSyncAgent{ + {r1CodexAgent: r1CodexAgent{principal: "codex-01@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-1"}, localCancel: func() {}}, + {r1CodexAgent: r1CodexAgent{principal: "codex-02@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-2"}, localCancel: func() {}}, + {r1CodexAgent: r1CodexAgent{principal: "codex-03@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-3"}, localCancel: func() {}}, + } + entries := []r1GitHubMeshScenarioEntry{{index: 0}} + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, nil); got != 0 { + t.Fatalf("integration agent = %d, want entrypoint when idle", got) + } + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true}); got != 1 { + t.Fatalf("integration agent = %d, want first idle teammate", got) + } + if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true, 1: true, 2: true}); got != -1 { + t.Fatalf("integration agent = %d, want none when all ready agents are busy", got) + } +} + +func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { + counts := map[string]map[string]int{ + "codex-01@project": {"assignment": 2, "progress_digest": 1}, + "codex-02@project": {"assignment": 1, "teamwork_signal": 1}, + } + if got := r1GitHubMeshKindTotal(counts, "assignment"); got != 3 { + t.Fatalf("assignment total = %d, want 3", got) + } + if got := r1GitHubMeshKindTotal(counts, "progress_digest"); got != 1 { + t.Fatalf("progress total = %d, want 1", got) + } + if got := r1GitHubMeshKindTotal(counts, "project_intent"); got != 0 { + t.Fatalf("missing kind total = %d, want 0", got) + } +} + +func TestR1GitHubMeshTeamEvidenceCountsReady(t *testing.T) { + counts := map[string]map[string]int{ + "codex-01@project": {"assignment": 2}, + "codex-02@project": {"progress_digest": 1}, + "codex-03@project": {"progress_digest": 1}, + } + if !r1GitHubMeshTeamEvidenceCountsReady(counts) { + t.Fatalf("team evidence should be ready for two non-profile participants: %+v", counts) + } + counts = map[string]map[string]int{ + "codex-01@project": {"assignment": 2}, + "codex-02@project": {"agent_profile": 1}, + } + if r1GitHubMeshTeamEvidenceCountsReady(counts) { + t.Fatalf("team evidence should require progress beyond assignment publication: %+v", counts) + } +} + +func TestR1GitHubMeshAuthoredEventCountsUseLocalSyncEvents(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspaces", "codex-01") + storePath := filepath.Join(workspace, runtime.DefaultStorePath) + if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", storePath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE sync_events(actor TEXT, resource_kind TEXT, status TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO sync_events(actor, resource_kind, status) VALUES + ('codex-01@project', 'assignment', 'synced'), + ('codex-01@project', 'assignment', 'pending'), + ('codex-02@project', 'progress_digest', 'synced'), + ('', 'progress_digest', 'synced')`); err != nil { + t.Fatal(err) + } + + counts, warnings := r1GitHubMeshAuthoredEventCounts([]r1CodexSyncAgent{{ + r1CodexAgent: r1CodexAgent{principal: "codex-01@project", workspace: workspace}, + }}) + if len(warnings) != 0 { + t.Fatalf("warnings = %v", warnings) + } + if got := counts["codex-01@project"]["assignment"]; got != 2 { + t.Fatalf("codex-01 assignment count = %d, want 2", got) + } + if got := counts["codex-02@project"]["progress_digest"]; got != 1 { + t.Fatalf("codex-02 progress count = %d, want 1", got) + } + if _, ok := counts[""]; ok { + t.Fatal("blank actors must not be counted") + } +} diff --git a/harness/cmd/mnemon-harness/acceptance_observe.go b/harness/cmd/mnemon-harness/acceptance_observe.go index 995acec1..845e8a49 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe.go +++ b/harness/cmd/mnemon-harness/acceptance_observe.go @@ -106,6 +106,7 @@ type acceptanceStoreInspect struct { Counts map[string]int `json:"counts"` EnvelopeByPhase map[string]int `json:"envelope_by_phase,omitempty"` EnvelopeByType map[string]int `json:"envelope_by_type,omitempty"` + ObservedByType map[string]int `json:"observed_by_type,omitempty"` SyncEventsByStatus map[string]int `json:"sync_events_by_status,omitempty"` RemoteEventsByStatus map[string]int `json:"remote_events_by_status,omitempty"` GovernedRowsByKind map[string]int `json:"governed_rows_by_kind,omitempty"` @@ -349,6 +350,10 @@ func inspectAcceptanceStore(root, path string, latest int) (acceptanceStoreInspe report.Counts["imported_accepted"] = sumCountMap(report.ImportedAcceptedByRef) } if report.Counts["events"] > 0 { + report.ObservedByType, err = sqliteObservedByType(ctx, db) + if err != nil { + return report, err + } report.LatestObserved, err = sqliteLatestObservedEvents(ctx, db, latest) if err != nil { return report, err @@ -472,6 +477,29 @@ func sqliteLatestObservedEvents(ctx context.Context, db *sql.DB, limit int) ([]a return out, rows.Err() } +func sqliteObservedByType(ctx context.Context, db *sql.DB) (map[string]int, error) { + rows, err := db.QueryContext(ctx, `SELECT payload FROM events`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var payload string + if err := rows.Scan(&payload); err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal([]byte(payload), &raw); err != nil { + continue + } + if eventType, _ := raw["type"].(string); eventType != "" { + out[eventType]++ + } + } + return out, rows.Err() +} + func sqliteImportedRemoteDecisions(ctx context.Context, db *sql.DB) (map[string]int, error) { rows, err := db.QueryContext(ctx, `SELECT payload FROM events WHERE payload LIKE '%remote_synced_event.observed%'`) if err != nil { diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index ebb9f6e4..58be586c 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -12,6 +12,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -21,10 +22,14 @@ var ( syncStorePath string syncRemotesPath string syncRemoteID string + syncRemoteBackend string + syncRemoteDirection string syncRemoteURL string syncRemoteToken string syncRemoteTokenFile string syncCAFile string + syncGitHubRepo string + syncGitHubBranch string syncAllowInsecure bool syncOnce bool syncBackground bool @@ -66,10 +71,14 @@ func init() { syncCmd.PersistentFlags().StringVar(&syncStorePath, "store", "", "Local Mnemon store path") syncCmd.PersistentFlags().StringVar(&syncRemotesPath, "remotes", "", "Remote Workspace config path") syncCmd.PersistentFlags().StringVar(&syncRemoteID, "remote", "default", "Remote Workspace id") + syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http or github)") + syncCmd.PersistentFlags().StringVar(&syncRemoteDirection, "direction", "", "Remote Workspace direction (bidirectional, publish, or subscribe)") syncCmd.PersistentFlags().StringVar(&syncRemoteURL, "remote-url", "", "Remote Workspace sync endpoint") syncCmd.PersistentFlags().StringVar(&syncRemoteToken, "token", "", "Remote Workspace sync token") syncCmd.PersistentFlags().StringVar(&syncRemoteTokenFile, "token-file", "", "Remote Workspace sync token file") syncCmd.PersistentFlags().StringVar(&syncCAFile, "ca-file", "", "PEM bundle pinning the Remote Workspace TLS root (e.g. the mnemon-hub --dev-selfsigned cert)") + syncCmd.PersistentFlags().StringVar(&syncGitHubRepo, "github-repo", "", "GitHub Remote Workspace repository (owner/name)") + syncCmd.PersistentFlags().StringVar(&syncGitHubBranch, "github-branch", "", "GitHub Remote Workspace publication branch") syncCmd.PersistentFlags().BoolVar(&syncAllowInsecure, "allow-insecure-remote", false, "explicitly allow a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") _ = syncCmd.PersistentFlags().MarkHidden("store") _ = syncCmd.PersistentFlags().MarkHidden("remotes") @@ -92,19 +101,46 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { return fmt.Errorf("Remote Workspace name must use letters, numbers, dot, dash, or underscore") } endpoint := strings.TrimSpace(syncRemoteURL) - if endpoint == "" { - return fmt.Errorf("--remote-url is required") + backend, err := exchange.NormalizeRemoteBackend(syncRemoteBackend) + if err != nil { + return err } - // T2 downgrade gate at WRITE time (v1.1 #3): a plaintext non-loopback endpoint never enters - // remotes.json unless explicitly overridden — the worker and the manual verbs then re-validate - // at client construction. - if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { + direction, err := exchange.NormalizeRemoteDirection(syncRemoteDirection) + if err != nil { return err } + repo, branch := "", "" + switch backend { + case exchange.RemoteBackendHTTP: + if endpoint == "" { + return fmt.Errorf("--remote-url is required") + } + // T2 downgrade gate at WRITE time (v1.1 #3): a plaintext non-loopback endpoint never enters + // remotes.json unless explicitly overridden — the worker and the manual verbs then re-validate + // at client construction. + if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { + return err + } + case exchange.RemoteBackendGitHub: + repo, err = exchange.NormalizeGitHubRepo(syncGitHubRepo) + if err != nil { + return err + } + branch, err = exchange.NormalizePublicationBranch(syncGitHubBranch) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported Remote Workspace backend %q", backend) + } if strings.TrimSpace(syncRemoteToken) == "" && strings.TrimSpace(syncRemoteTokenFile) == "" { return fmt.Errorf("--token or --token-file is required") } - if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, endpoint, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { + directionForWrite := direction + if backend == exchange.RemoteBackendHTTP && direction == exchange.RemoteDirectionBidirectional { + directionForWrite = "" + } + if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, repo, branch, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Remote Workspace: connected %s\n", workspace) @@ -200,74 +236,135 @@ func syncPushOnce() (syncPushResult, error) { if len(batch.Events) == 0 { return syncPushResult{}, nil } - remote, err := resolveSyncRemote() + plan, err := resolveSyncRemotePlan() if err != nil { return syncPushResult{}, err } - client, err := syncClientFor(remote) - if err != nil { - return syncPushResult{}, err - } - resp, err := client.SyncPush(contract.SyncPushRequest{ - ReplicaID: batch.ReplicaID, - BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), - Events: batch.Events, - }) - if err != nil { - return syncPushResult{}, fmt.Errorf("sync push failed: %w", err) + if len(plan.PushTargets) == 0 { + return syncPushResult{}, fmt.Errorf("Remote Workspace plan has no push targets") } - if err := exchange.ApplyLocalSyncPushResponse(storePath, remote.ID, resp); err != nil { - return syncPushResult{}, err + result := syncPushResult{} + for _, remote := range plan.PushTargets { + workspace, err := syncRemoteWorkspaceFor(remote) + if err != nil { + return syncPushResult{}, err + } + resp, err := workspace.SyncPush(contract.SyncPushRequest{ + ReplicaID: batch.ReplicaID, + BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), + Events: batch.Events, + }) + if err != nil { + return syncPushResult{}, fmt.Errorf("sync push failed: %w", err) + } + if err := exchange.ApplyLocalSyncPushResponse(storePath, remote.ID, resp); err != nil { + return syncPushResult{}, err + } + result.accepted += len(resp.Accepted) + result.rejected += len(resp.Rejected) + result.conflicts += len(resp.Conflicts) } - return syncPushResult{accepted: len(resp.Accepted), rejected: len(resp.Rejected), conflicts: len(resp.Conflicts)}, nil + return result, nil } func syncPullOnce() (syncPullResult, error) { - remote, err := resolveSyncRemote() + plan, err := resolveSyncRemotePlan() if err != nil { return syncPullResult{}, err } - storePath := resolvedSyncStorePath() - state, err := exchange.ReadLocalSyncPullState(storePath, remote.ID) - if err != nil { - return syncPullResult{}, err - } - client, err := syncClientFor(remote) - if err != nil { - return syncPullResult{}, err - } - resp, err := client.SyncPull(contract.SyncPullRequest{ - ReplicaID: state.ReplicaID, - RemoteCursor: state.RemoteCursor, - }) - if err != nil { - return syncPullResult{}, fmt.Errorf("sync pull failed: %w", err) + if len(plan.PullSources) == 0 { + return syncPullResult{}, fmt.Errorf("Remote Workspace plan has no pull sources") } + storePath := resolvedSyncStorePath() catalog := app.SyncImportCatalog(syncProjectRoot(), os.Stderr) - if err := app.ImportLocalSyncPull(storePath, remote.ID, resp.NextCursor, resp.Events, catalog); err != nil { - return syncPullResult{}, err + result := syncPullResult{} + for _, remote := range plan.PullSources { + state, err := exchange.ReadLocalSyncPullState(storePath, remote.ID) + if err != nil { + return syncPullResult{}, err + } + workspace, err := syncRemoteWorkspaceFor(remote) + if err != nil { + return syncPullResult{}, err + } + resp, err := workspace.SyncPull(contract.SyncPullRequest{ + ReplicaID: state.ReplicaID, + RemoteCursor: state.RemoteCursor, + }) + if err != nil { + return syncPullResult{}, fmt.Errorf("sync pull failed: %w", err) + } + if err := app.ImportLocalSyncPullWithDiagnostics(storePath, remote.ID, resp.NextCursor, resp.Events, resp.Diagnostics, catalog); err != nil { + return syncPullResult{}, err + } + result.events += len(resp.Events) } - return syncPullResult{events: len(resp.Events)}, nil + return result, nil } type syncRemoteConfig struct { ID string + Backend string Endpoint string + Repo string + Branch string Token string CAFile string } -// syncClientFor builds the bounded sync client for one resolved remote: bearer token, optional -// pinned TLS root, and the T2 downgrade gate (--allow-insecure-remote is the only override). -func syncClientFor(remote syncRemoteConfig) (*access.Client, error) { - return access.NewSyncClient(remote.Endpoint, access.SyncClientConfig{ - Token: remote.Token, - CAFile: remote.CAFile, - AllowInsecure: syncAllowInsecure, - }) +type syncRemotePlan struct { + PushTargets []syncRemoteConfig + PullSources []syncRemoteConfig +} + +// syncRemoteWorkspaceFor builds the selected Remote Workspace backend for one resolved remote. The +// current CLI supports the first-party HTTP mnemon-hub backend; future backends must preserve this +// SyncPush/SyncPull/SyncStatus ABI rather than bypassing local import. +func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, error) { + backend := strings.TrimSpace(remote.Backend) + if backend == "" { + backend = exchange.RemoteBackendHTTP + } + switch backend { + case exchange.RemoteBackendHTTP: + return access.NewSyncClient(remote.Endpoint, access.SyncClientConfig{ + Token: remote.Token, + CAFile: remote.CAFile, + AllowInsecure: syncAllowInsecure, + }) + case exchange.RemoteBackendGitHub: + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: remote.Repo, + Token: remote.Token, + }) + if err != nil { + return nil, err + } + return githubbackend.New(githubbackend.Config{ + Store: store, + Repo: remote.Repo, + Branch: remote.Branch, + }) + default: + return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", remote.ID, backend) + } } func resolveSyncRemote() (syncRemoteConfig, error) { + plan, err := resolveSyncRemotePlan() + if err != nil { + return syncRemoteConfig{}, err + } + if len(plan.PushTargets) > 0 { + return plan.PushTargets[0], nil + } + if len(plan.PullSources) > 0 { + return plan.PullSources[0], nil + } + return syncRemoteConfig{}, fmt.Errorf("Remote Workspace plan has no remotes") +} + +func resolveSyncRemotePlan() (syncRemotePlan, error) { if strings.TrimSpace(syncRemoteURL) != "" { tokenFile := syncRemoteTokenFile if tokenFile != "" { @@ -275,26 +372,48 @@ func resolveSyncRemote() (syncRemoteConfig, error) { } token, err := resolveSyncToken(syncRemoteToken, tokenFile) if err != nil { - return syncRemoteConfig{}, err + return syncRemotePlan{}, err } - return syncRemoteConfig{ID: syncRemoteID, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")}, nil + remote := syncRemoteConfig{ID: syncRemoteID, Backend: exchange.RemoteBackendHTTP, Endpoint: syncRemoteURL, Token: token, CAFile: resolvedSyncCAFile("")} + return syncRemotePlan{PushTargets: []syncRemoteConfig{remote}, PullSources: []syncRemoteConfig{remote}}, nil } - entry, err := exchange.LoadRemoteEntry(resolvedSyncRemotesPath(), syncRemoteID) + plan, err := exchange.LoadRemotePlan(resolvedSyncRemotesPath(), syncRemoteID) if err != nil { - return syncRemoteConfig{}, err + return syncRemotePlan{}, err + } + out := syncRemotePlan{} + for _, entry := range plan.PushTargets { + remote, err := resolveSyncRemoteEntry(entry) + if err != nil { + return syncRemotePlan{}, err + } + out.PushTargets = append(out.PushTargets, remote) } + for _, entry := range plan.PullSources { + remote, err := resolveSyncRemoteEntry(entry) + if err != nil { + return syncRemotePlan{}, err + } + out.PullSources = append(out.PullSources, remote) + } + return out, nil +} + +func resolveSyncRemoteEntry(entry exchange.RemoteEntry) (syncRemoteConfig, error) { if strings.TrimSpace(entry.CredentialRef) == "" && strings.TrimSpace(syncRemoteToken) == "" && strings.TrimSpace(syncRemoteTokenFile) == "" { return syncRemoteConfig{}, fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) } tokenFile := "" - if strings.TrimSpace(entry.CredentialRef) != "" { + if strings.TrimSpace(syncRemoteTokenFile) != "" { + tokenFile = resolveSyncPath(syncRemoteTokenFile) + } else if strings.TrimSpace(entry.CredentialRef) != "" { tokenFile = resolveSyncPath(entry.CredentialRef) } token, err := resolveSyncToken(syncRemoteToken, tokenFile) if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: entry.ID, Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil + return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Repo: entry.Repo, Branch: entry.Branch, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil } // resolvedSyncCAFile picks the pinned-root file: the --ca-file flag overrides the remotes.json @@ -310,7 +429,7 @@ func resolvedSyncCAFile(entryCAFile string) string { return resolveSyncPath(caFile) } -func upsertSyncRemote(path, root, id, endpoint, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -326,7 +445,7 @@ func upsertSyncRemote(path, root, id, endpoint, token, tokenFile, caFile string) if err != nil { return err } - entry := exchange.RemoteEntry{ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} + entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, Repo: repo, Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} replaced := false for i := range doc.Remotes { if doc.Remotes[i].ID == id { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index 74d7fc16..3162663a 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "net/http/httptest" "os" "path/filepath" @@ -15,6 +16,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -290,7 +292,7 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { } } config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) - for _, want := range []string{`"current": "team"`, `"id": "team"`, `"credential_ref": ".mnemon/harness/sync/credentials/team.token"`} { + for _, want := range []string{`"current": "team"`, `"backend": "http"`, `"id": "team"`, `"credential_ref": ".mnemon/harness/sync/credentials/team.token"`} { if !strings.Contains(config, want) { t.Fatalf("sync connect config missing %q:\n%s", want, config) } @@ -305,11 +307,61 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { if err != nil { t.Fatalf("resolve current remote: %v", err) } - if remote.ID != "team" || remote.Endpoint != "https://remote.example.test" || remote.Token != "secret-workspace-token" { + if remote.ID != "team" || remote.Backend != exchange.RemoteBackendHTTP || remote.Endpoint != "https://remote.example.test" || remote.Token != "secret-workspace-token" { t.Fatalf("current remote not resolved: %+v", remote) } } +func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { + restoreSyncFlags(t) + root := t.TempDir() + syncRoot = root + syncRemoteBackend = exchange.RemoteBackendGitHub + syncRemoteDirection = exchange.RemoteDirectionPublish + syncGitHubRepo = "mnemon-dev/mnemon-teamwork-example" + syncGitHubBranch = "mnemon/mnemond-a" + syncRemoteToken = "secret-github-token" + var out bytes.Buffer + cmd := mustTestCommand(t) + cmd.SetOut(&out) + if err := runSyncConnect(cmd, []string{"self"}); err != nil { + t.Fatalf("sync connect github: %v", err) + } + if strings.Contains(out.String(), "secret-github-token") { + t.Fatalf("sync connect output must not expose token:\n%s", out.String()) + } + config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) + for _, want := range []string{ + `"backend": "github"`, + `"direction": "publish"`, + `"id": "self"`, + `"repo": "mnemon-dev/mnemon-teamwork-example"`, + `"branch": "mnemon/mnemond-a"`, + `"credential_ref": ".mnemon/harness/sync/credentials/self.token"`, + } { + if !strings.Contains(config, want) { + t.Fatalf("sync connect github config missing %q:\n%s", want, config) + } + } + if strings.Contains(config, "secret-github-token") || strings.Contains(config, "endpoint") { + t.Fatalf("github remote config must not leak token or write an endpoint:\n%s", config) + } + syncRemoteBackend = "" + syncRemoteDirection = "" + syncGitHubRepo = "" + syncGitHubBranch = "" + syncRemoteToken = "" + remote, err := resolveSyncRemote() + if err != nil { + t.Fatalf("resolve github remote: %v", err) + } + if remote.ID != "self" || remote.Backend != exchange.RemoteBackendGitHub || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/mnemond-a" || + remote.Token != "secret-github-token" { + t.Fatalf("github remote not resolved: %+v", remote) + } +} + func TestSyncRemoteConfigLoadsCredentialRef(t *testing.T) { restoreSyncFlags(t) root := t.TempDir() @@ -341,41 +393,101 @@ func TestSyncRemoteConfigLoadsCredentialRef(t *testing.T) { if err != nil { t.Fatalf("resolve remote config: %v", err) } - if remote.ID != "workspace" || remote.Endpoint != "http://127.0.0.1:8787" || remote.Token != "tok-workspace" { + if remote.ID != "workspace" || remote.Backend != exchange.RemoteBackendHTTP || remote.Endpoint != "http://127.0.0.1:8787" || remote.Token != "tok-workspace" { t.Fatalf("remote config not loaded: %+v", remote) } } +func TestSyncRemotePlanLoadsDirectionalCredentials(t *testing.T) { + restoreSyncFlags(t) + root := t.TempDir() + writeCredential := func(id, token string) string { + t.Helper() + credRel := filepath.Join(".mnemon", "harness", "sync", "credentials", id+".token") + credPath := filepath.Join(root, credRel) + if err := os.MkdirAll(filepath.Dir(credPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(credPath, []byte(token+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return filepath.ToSlash(credRel) + } + pubCred := writeCredential("pub", "tok-pub") + subCred := writeCredential("sub", "tok-sub") + remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") + if err := os.WriteFile(remotesPath, []byte(fmt.Sprintf(`{ + "schema_version": 1, + "remotes": [{ + "id": "pub", + "direction": "publish", + "endpoint": "http://127.0.0.1:8787", + "credential_ref": %q + }, { + "id": "sub", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:8788", + "credential_ref": %q + }] + }`+"\n", pubCred, subCred)), 0o644); err != nil { + t.Fatal(err) + } + syncRoot = root + + plan, err := resolveSyncRemotePlan() + if err != nil { + t.Fatalf("resolve directional remote plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "pub" || plan.PushTargets[0].Token != "tok-pub" { + t.Fatalf("push target not resolved with its credential: %+v", plan.PushTargets) + } + if len(plan.PullSources) != 1 || plan.PullSources[0].ID != "sub" || plan.PullSources[0].Token != "tok-sub" { + t.Fatalf("pull source not resolved with its credential: %+v", plan.PullSources) + } +} + func restoreSyncFlags(t *testing.T) { t.Helper() oldRoot := syncRoot oldStorePath := syncStorePath oldRemotesPath := syncRemotesPath oldRemoteID := syncRemoteID + oldRemoteBackend := syncRemoteBackend + oldRemoteDirection := syncRemoteDirection oldRemoteURL := syncRemoteURL oldRemoteToken := syncRemoteToken oldRemoteTokenFile := syncRemoteTokenFile oldCAFile := syncCAFile + oldGitHubRepo := syncGitHubRepo + oldGitHubBranch := syncGitHubBranch oldAllowInsecure := syncAllowInsecure t.Cleanup(func() { syncRoot = oldRoot syncStorePath = oldStorePath syncRemotesPath = oldRemotesPath syncRemoteID = oldRemoteID + syncRemoteBackend = oldRemoteBackend + syncRemoteDirection = oldRemoteDirection syncRemoteURL = oldRemoteURL syncRemoteToken = oldRemoteToken syncRemoteTokenFile = oldRemoteTokenFile syncCAFile = oldCAFile + syncGitHubRepo = oldGitHubRepo + syncGitHubBranch = oldGitHubBranch syncAllowInsecure = oldAllowInsecure }) syncRoot = "." syncStorePath = "" syncRemotesPath = "" syncRemoteID = "default" + syncRemoteBackend = "" + syncRemoteDirection = "" syncRemoteURL = "" syncRemoteToken = "" syncRemoteTokenFile = "" syncCAFile = "" + syncGitHubRepo = "" + syncGitHubBranch = "" syncAllowInsecure = false } diff --git a/harness/internal/app/local_runtime.go b/harness/internal/app/local_runtime.go index 3f354864..7cb143b0 100644 --- a/harness/internal/app/local_runtime.go +++ b/harness/internal/app/local_runtime.go @@ -56,6 +56,7 @@ func withSyncImport(rc runtime.RuntimeConfig, bindings []access.ChannelBinding, rules := append([]admission.Rule(nil), rc.Rules.Rules()...) rules = append(rules, policy.RemoteImportRules(catalog, contract.SyncImportActor)...) rules = append(rules, policy.SyncImportSkippedRule(contract.SyncImportActor)) + rules = append(rules, policy.SyncRemoteDiagnosticRule(contract.SyncImportActor)) rc.Rules = admission.NewRuleSet(rules...) if rc.Subs == nil { rc.Subs = map[contract.ActorID]contract.Subscription{} @@ -380,7 +381,8 @@ func SyncImportRuntimeConfig(refs []contract.ResourceRef, catalog policy.Registr } } rules := append(policy.RemoteImportRules(catalog, contract.SyncImportActor), - policy.SyncImportSkippedRule(contract.SyncImportActor)) + policy.SyncImportSkippedRule(contract.SyncImportActor), + policy.SyncRemoteDiagnosticRule(contract.SyncImportActor)) return runtime.RuntimeConfig{ Subs: map[contract.ActorID]contract.Subscription{ contract.SyncImportActor: {Actor: contract.SyncImportActor, Refs: refs}, diff --git a/harness/internal/app/local_sync.go b/harness/internal/app/local_sync.go index 2422577c..71ac6f3e 100644 --- a/harness/internal/app/local_sync.go +++ b/harness/internal/app/local_sync.go @@ -1,6 +1,8 @@ package app import ( + "crypto/sha256" + "encoding/hex" "fmt" "strings" "time" @@ -18,10 +20,21 @@ import ( // boots its own import runtime by path, so it must never run inside a serving process (the in-process // worker drives importPulledEvents over the LIVE runtime instead — flock, v1.1 #2). func ImportLocalSyncPull(storePath, remoteID, nextCursor string, events []eventmodel.EventEnvelope, catalog policy.Registry) error { - if len(events) > 0 { - refs, err := refsFromSyncedEvents(events) - if err != nil { - return err + return ImportLocalSyncPullWithDiagnostics(storePath, remoteID, nextCursor, events, nil, catalog) +} + +// ImportLocalSyncPullWithDiagnostics is ImportLocalSyncPull plus pull-side Remote Workspace +// diagnostics. Diagnostics enter as trusted sync.remote_diagnostic.observed observations and are +// converted by policy into durable sync.diagnostic events, exactly like skipped-kind diagnostics. +func ImportLocalSyncPullWithDiagnostics(storePath, remoteID, nextCursor string, events []eventmodel.EventEnvelope, diagnostics []contract.EventExchangeResult, catalog policy.Registry) error { + if len(events) > 0 || len(diagnostics) > 0 { + var refs []contract.ResourceRef + if len(events) > 0 { + var err error + refs, err = refsFromSyncedEvents(events) + if err != nil { + return err + } } rt, err := OpenSyncImportRuntime(storePath, refs, catalog) if err != nil { @@ -31,6 +44,10 @@ func ImportLocalSyncPull(storePath, remoteID, nextCursor string, events []eventm _ = rt.Close() return err } + if err := importRemoteDiagnostics(rt, remoteID, diagnostics); err != nil { + _ = rt.Close() + return err + } if err := rt.Close(); err != nil { return err } @@ -93,6 +110,40 @@ func importPulledEvents(rt *runtime.Runtime, remoteID string, events []eventmode return nil } +func importRemoteDiagnostics(rt *runtime.Runtime, remoteID string, diagnostics []contract.EventExchangeResult) error { + if len(diagnostics) == 0 { + return nil + } + pulledAt := time.Now().UTC().Format(time.RFC3339) + for _, item := range diagnostics { + env := contract.ObservationEnvelope{ + ExternalID: syncRemoteDiagnosticExternalID(remoteID, item), + Event: contract.Event{ + Type: policy.SyncRemoteDiagnosticObserved, + Payload: map[string]any{ + "remote_id": remoteID, + "origin_mnemond": item.OriginMnemond, + "event_id": item.EventID, + "subject": string(item.Subject), + "status": item.Status, + "diagnostic": item.Diagnostic, + "pulled_at": pulledAt, + }, + }, + } + _, dup, err := rt.IngestTrusted(contract.SyncImportActor, env) + if err != nil { + return fmt.Errorf("ingest remote workspace diagnostic: %w", err) + } + if !dup { + if _, err := rt.Tick(); err != nil { + return fmt.Errorf("apply remote workspace diagnostic: %w", err) + } + } + } + return nil +} + func refsFromSyncedEvents(events []eventmodel.EventEnvelope) ([]contract.ResourceRef, error) { seen := map[contract.ResourceRef]bool{} var refs []contract.ResourceRef @@ -119,3 +170,15 @@ func syncPullExternalID(remoteID string, material contract.SyncedEventMaterial) string(material.ResourceRef.ID), }, ":") } + +func syncRemoteDiagnosticExternalID(remoteID string, item contract.EventExchangeResult) string { + sum := sha256.Sum256([]byte(strings.Join([]string{ + remoteID, + item.OriginMnemond, + item.EventID, + string(item.Subject), + item.Status, + item.Diagnostic, + }, "\x00"))) + return "pull:" + remoteID + ":diagnostic:" + hex.EncodeToString(sum[:12]) +} diff --git a/harness/internal/app/sync_github_live_test.go b/harness/internal/app/sync_github_live_test.go new file mode 100644 index 00000000..70f32e89 --- /dev/null +++ b/harness/internal/app/sync_github_live_test.go @@ -0,0 +1,190 @@ +package app + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestGitHubLivePublishPullImport(t *testing.T) { + cfg := liveGitHubTestConfig(t) + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: cfg.repo, + Token: cfg.token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }) + if err != nil { + t.Fatalf("github publication store: %v", err) + } + remoteA := liveGitHubRemote(t, store, cfg.repo, cfg.branchA) + remoteB := liveGitHubRemote(t, store, cfg.repo, cfg.branchB) + + sourcePrincipal := contract.ActorID("codex-github-live-a@project") + targetPrincipal := contract.ActorID("codex-github-live-b@project") + source := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "source"), string(sourcePrincipal)) + target := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "target"), string(targetPrincipal)) + + runID := "github-live-" + time.Now().UTC().Format("20060102T150405.000000000Z") + assignmentID := runID + "-assignment" + assignmentScope := "github-live/publish-pull-import/" + runID + observeLiveAssignment(t, source, sourcePrincipal, assignmentID, assignmentScope, targetPrincipal) + + if err := syncWorkerPush(source, remoteA, "github-live-publish-a"); err != nil { + t.Fatalf("source publish branch %s: %v", cfg.branchA, err) + } + if pending, err := source.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("source publish must drain pending events, pending=%+v err=%v", pending, err) + } + + if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { + t.Fatalf("target pull branch %s: %v", cfg.branchA, err) + } + assertAssignmentScopeCount(t, target, assignmentScope, 1) + if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { + t.Fatalf("target repeat pull branch %s: %v", cfg.branchA, err) + } + assertAssignmentScopeCount(t, target, assignmentScope, 1) + + progressSummary := runID + " completed assignment " + assignmentID + observeLiveProgress(t, target, targetPrincipal, runID+"-progress", progressSummary) + if err := syncWorkerPush(target, remoteB, "github-live-publish-b"); err != nil { + t.Fatalf("target publish branch %s: %v", cfg.branchB, err) + } + if pending, err := target.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("target publish must drain pending events, pending=%+v err=%v", pending, err) + } + + if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { + t.Fatalf("source pull branch %s: %v", cfg.branchB, err) + } + assertProgressSummaryCount(t, source, progressSummary, 1) + if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { + t.Fatalf("source repeat pull branch %s: %v", cfg.branchB, err) + } + assertProgressSummaryCount(t, source, progressSummary, 1) +} + +type liveGitHubConfig struct { + repo string + branchA string + branchB string + token string +} + +func liveGitHubTestConfig(t *testing.T) liveGitHubConfig { + t.Helper() + if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { + t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publish/pull/import test") + } + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if tokenFile := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_TOKEN_FILE")); tokenFile != "" { + raw, err := os.ReadFile(tokenFile) + if err != nil { + t.Fatalf("read MNEMON_GITHUB_TOKEN_FILE: %v", err) + } + token = strings.TrimSpace(string(raw)) + } + if token == "" { + t.Skip("GITHUB_TOKEN or MNEMON_GITHUB_TOKEN_FILE is required for live GitHub publish/pull/import test") + } + repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) + if repo == "" { + repo = "mnemon-dev/mnemon-teamwork-example" + } + branchA := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_A")) + if branchA == "" { + branchA = "mnemon/agent-a" + } + branchB := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_B")) + if branchB == "" { + branchB = "mnemon/agent-b" + } + return liveGitHubConfig{repo: repo, branchA: branchA, branchB: branchB, token: token} +} + +func liveGitHubRemote(t *testing.T, store exchange.PublicationStore, repo, branch string) exchange.RemoteWorkspace { + t.Helper() + remote, err := githubbackend.New(githubbackend.Config{ + Store: store, + Repo: repo, + Branch: branch, + Scopes: []contract.ResourceRef{ + {Kind: "assignment", ID: "project"}, + {Kind: "progress_digest", ID: "project"}, + }, + }) + if err != nil { + t.Fatalf("github remote %s: %v", branch, err) + } + return remote +} + +func observeLiveAssignment(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, assignmentID, scope string, assignee contract.ActorID) { + t.Helper() + if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ + ExternalID: assignmentID, + Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: map[string]any{ + "assignment_id": assignmentID, + "scope": scope, + "ttl": "30m", + "assignee": string(assignee), + "expected_work": "complete live GitHub Remote Workspace publish/pull/import validation", + "expected_feedback": "progress_digest with result evidence", + "evidence": "gated live GitHub publication backend test", + }}, + }); err != nil { + t.Fatalf("observe live assignment: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick live assignment: %v", err) + } +} + +func observeLiveProgress(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, externalID, summary string) { + t.Helper() + if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ + ExternalID: externalID, + Event: contract.Event{Type: "progress_digest.write_candidate.observed", Payload: map[string]any{ + "summary": summary, + }}, + }); err != nil { + t.Fatalf("observe live progress: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick live progress: %v", err) + } +} + +func assertAssignmentScopeCount(t *testing.T, rt *runtime.Runtime, scope string, want int) { + t.Helper() + _, fields, err := rt.Resource(contract.ResourceRef{Kind: "assignment", ID: "project"}) + if err != nil { + t.Fatalf("read assignment: %v", err) + } + content, _ := fields["content"].(string) + if got := strings.Count(content, scope); got != want { + t.Fatalf("assignment scope %q count = %d, want %d\n%s", scope, got, want, content) + } +} + +func assertProgressSummaryCount(t *testing.T, rt *runtime.Runtime, summary string, want int) { + t.Helper() + _, fields, err := rt.Resource(contract.ResourceRef{Kind: "progress_digest", ID: "project"}) + if err != nil { + t.Fatalf("read progress_digest: %v", err) + } + content, _ := fields["content"].(string) + if got := strings.Count(content, summary); got != want { + t.Fatalf("progress summary %q count = %d, want %d\n%s", summary, got, want, content) + } +} diff --git a/harness/internal/app/sync_github_mesh_test.go b/harness/internal/app/sync_github_mesh_test.go new file mode 100644 index 00000000..d66bca99 --- /dev/null +++ b/harness/internal/app/sync_github_mesh_test.go @@ -0,0 +1,284 @@ +package app + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +func TestSyncGitHubFakeFiveMnemondPublicationMesh(t *testing.T) { + ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e"} + branches := make([]string, 0, len(ids)) + for _, id := range ids { + branches = append(branches, "mnemon/"+id) + } + store, err := exchange.NewMemoryPublicationStore(branches...) + if err != nil { + t.Fatal(err) + } + type node struct { + id string + branch string + principal string + rt *runtime.Runtime + } + nodes := make([]node, 0, len(ids)) + for i, id := range ids { + root := t.TempDir() + principal := "codex-" + id + "@project" + rt := openMeshServingRuntime(t, root, principal) + observeMeshAssignment(t, rt, principal, id) + nodes = append(nodes, node{id: id, branch: branches[i], principal: principal, rt: rt}) + } + + for _, n := range nodes { + remote := githubFakeRemote(t, store, n.branch) + if err := syncWorkerPush(n.rt, remote, "publish-"+n.id); err != nil { + t.Fatalf("%s publish: %v", n.id, err) + } + if pending, err := n.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", n.id, pending, err) + } + } + + for _, n := range nodes { + for _, source := range nodes { + if source.id == n.id { + continue + } + remote := githubFakeRemote(t, store, source.branch) + state, err := exchange.ReadPullState(n.rt, "subscribe-"+source.id) + if err != nil { + t.Fatalf("%s read pull state %s: %v", n.id, source.id, err) + } + probe, err := remote.SyncPull(contract.SyncPullRequest{ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor}) + if err != nil { + t.Fatalf("%s probe %s: %v", n.id, source.id, err) + } + if len(probe.Diagnostics) > 0 || len(probe.Events) == 0 { + t.Fatalf("%s probe %s returned events=%d diagnostics=%+v", n.id, source.id, len(probe.Events), probe.Diagnostics) + } + if err := syncWorkerPull(n.rt, remote, "subscribe-"+source.id, nil); err != nil { + t.Fatalf("%s subscribe %s: %v", n.id, source.id, err) + } + } + } + + assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} + for _, n := range nodes { + _, fields, err := n.rt.Resource(assignmentRef) + if err != nil { + t.Fatalf("%s read assignments: %v", n.id, err) + } + items, _ := fields["items"].([]any) + scopes := map[string]bool{} + for _, item := range items { + m, _ := item.(map[string]any) + scope, _ := m["scope"].(string) + scopes[scope] = true + } + for _, source := range nodes { + want := meshAssignmentScope(source.id) + if !scopes[want] { + t.Fatalf("%s assignments missing %q in %+v", n.id, want, items) + } + } + } +} + +func TestSyncGitHubFakePublicationMeshJoinLeaveReassignment(t *testing.T) { + ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f", "agent-g"} + branches := make([]string, 0, len(ids)) + for _, id := range ids { + branches = append(branches, "mnemon/"+id) + } + store, err := exchange.NewMemoryPublicationStore(branches...) + if err != nil { + t.Fatal(err) + } + + nodes := make([]meshTestNode, 0, len(ids)) + for _, id := range ids[:5] { + node := newMeshTestNode(t, id) + observeMeshAssignment(t, node.rt, node.principal, id) + publishMeshNode(t, store, node) + nodes = append(nodes, node) + } + pullMeshAllSources(t, store, nodes[:5], nodes[:5]) + + for _, id := range ids[5:] { + node := newMeshTestNode(t, id) + observeMeshAssignment(t, node.rt, node.principal, id) + publishMeshNode(t, store, node) + nodes = append(nodes, node) + } + offline := nodes[3] + active := append([]meshTestNode{}, nodes[:3]...) + active = append(active, nodes[4:]...) + reassignScope := "agent-d down; reassign delayed work to agent-f" + observeMeshAssignmentWithScope(t, nodes[0].rt, nodes[0].principal, "reassign-agent-d", reassignScope, "codex@agent-f") + publishMeshNode(t, store, nodes[0]) + + pullMeshAllSources(t, store, active, nodes) + assertMeshScopes(t, active, []string{ + meshAssignmentScope("agent-a"), + meshAssignmentScope("agent-b"), + meshAssignmentScope("agent-c"), + meshAssignmentScope("agent-d"), + meshAssignmentScope("agent-e"), + meshAssignmentScope("agent-f"), + meshAssignmentScope("agent-g"), + reassignScope, + }) + assertMeshScopesAbsent(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) + + pullMeshAllSources(t, store, []meshTestNode{offline}, nodes) + assertMeshScopes(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) +} + +type meshTestNode struct { + id string + branch string + principal string + rt *runtime.Runtime +} + +func newMeshTestNode(t *testing.T, id string) meshTestNode { + t.Helper() + root := t.TempDir() + principal := "codex-" + id + "@project" + return meshTestNode{ + id: id, + branch: "mnemon/" + id, + principal: principal, + rt: openMeshServingRuntime(t, root, principal), + } +} + +func publishMeshNode(t *testing.T, store exchange.PublicationStore, node meshTestNode) { + t.Helper() + remote := githubFakeRemote(t, store, node.branch) + if err := syncWorkerPush(node.rt, remote, "publish-"+node.id); err != nil { + t.Fatalf("%s publish: %v", node.id, err) + } + if pending, err := node.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { + t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", node.id, pending, err) + } +} + +func pullMeshAllSources(t *testing.T, store exchange.PublicationStore, targets, sources []meshTestNode) { + t.Helper() + for _, target := range targets { + for _, source := range sources { + if source.id == target.id { + continue + } + remote := githubFakeRemote(t, store, source.branch) + if err := syncWorkerPull(target.rt, remote, "subscribe-"+source.id, nil); err != nil { + t.Fatalf("%s subscribe %s: %v", target.id, source.id, err) + } + } + } +} + +func assertMeshScopes(t *testing.T, nodes []meshTestNode, scopes []string) { + t.Helper() + for _, node := range nodes { + got := meshScopes(t, node) + for _, want := range scopes { + if !got[want] { + t.Fatalf("%s assignments missing %q in %+v", node.id, want, got) + } + } + } +} + +func assertMeshScopesAbsent(t *testing.T, nodes []meshTestNode, scopes []string) { + t.Helper() + for _, node := range nodes { + got := meshScopes(t, node) + for _, want := range scopes { + if got[want] { + t.Fatalf("%s assignments unexpectedly contain %q in %+v", node.id, want, got) + } + } + } +} + +func meshScopes(t *testing.T, node meshTestNode) map[string]bool { + t.Helper() + assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} + _, fields, err := node.rt.Resource(assignmentRef) + if err != nil { + t.Fatalf("%s read assignments: %v", node.id, err) + } + items, _ := fields["items"].([]any) + scopes := map[string]bool{} + for _, item := range items { + m, _ := item.(map[string]any) + scope, _ := m["scope"].(string) + scopes[scope] = true + } + return scopes +} + +func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runtime { + t.Helper() + refs := []contract.ResourceRef{{Kind: "progress_digest", ID: "project"}, {Kind: "assignment", ID: "project"}} + b := access.HostAgentBinding(contract.ActorID(principal), "http://127.0.0.1:8787", refs) + rt, err := OpenLocalRuntime(filepath.Join(root, runtime.DefaultStorePath), access.LoadedBindings{Bindings: []access.ChannelBinding{b}}, nil, nil) + if err != nil { + t.Fatalf("open serving runtime: %v", err) + } + t.Cleanup(func() { _ = rt.Close() }) + return rt +} + +func observeMeshAssignment(t *testing.T, rt *runtime.Runtime, principal, id string) { + t.Helper() + observeMeshAssignmentWithScope(t, rt, principal, id, meshAssignmentScope(id), "codex@"+id) +} + +func observeMeshAssignmentWithScope(t *testing.T, rt *runtime.Runtime, principal, id, scope, assignee string) { + t.Helper() + if _, _, err := rt.API().Ingest(contract.ActorID(principal), contract.ObservationEnvelope{ + ExternalID: "github-mesh-assignment-" + id, + Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: map[string]any{ + "assignment_id": "mesh-" + id, + "scope": scope, + "ttl": "2h", + "assignee": assignee, + "expected_work": "complete deterministic publication mesh validation for " + id, + "expected_feedback": "progress_digest", + "evidence": "deterministic fake GitHub publication mesh test", + }}, + }); err != nil { + t.Fatalf("observe assignment: %v", err) + } + if _, err := rt.Tick(); err != nil { + t.Fatalf("tick assignment: %v", err) + } +} + +func meshAssignmentScope(id string) string { + return fmt.Sprintf("%s publication mesh assignment", id) +} + +func githubFakeRemote(t *testing.T, store exchange.PublicationStore, branch string) exchange.RemoteWorkspace { + t.Helper() + remote, err := githubbackend.New(githubbackend.Config{ + Store: store, + Repo: "mnemon-dev/mnemon-teamwork-example", + Branch: branch, + }) + if err != nil { + t.Fatal(err) + } + return remote +} diff --git a/harness/internal/app/sync_remote_diagnostic_test.go b/harness/internal/app/sync_remote_diagnostic_test.go new file mode 100644 index 00000000..9b9ca794 --- /dev/null +++ b/harness/internal/app/sync_remote_diagnostic_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/runtime" +) + +type staticPullRemoteWorkspace struct { + resp contract.SyncPullResponse +} + +func (s staticPullRemoteWorkspace) SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) { + return contract.SyncPushResponse{}, nil +} + +func (s staticPullRemoteWorkspace) SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) { + return s.resp, nil +} + +func (s staticPullRemoteWorkspace) SyncStatus() (contract.SyncStatusResponse, error) { + return contract.SyncStatusResponse{}, nil +} + +func countRemoteDiagnostics(t *testing.T, rt *runtime.Runtime, remoteID string, want string) int { + t.Helper() + events, err := rt.PendingEvents(0) + if err != nil { + t.Fatalf("events: %v", err) + } + n := 0 + for _, ev := range events { + if ev.Type == "sync.remote_diagnostic.observed" && + ev.Payload["remote_id"] == remoteID && + strings.Contains(stringPayload(ev.Payload, "diagnostic"), want) { + n++ + } + if ev.Type == "sync.diagnostic" && + strings.Contains(stringPayload(ev.Payload, "reason"), want) { + n++ + } + } + return n +} + +func stringPayload(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return value +} + +func TestWorkerPullRemoteDiagnosticLandsDurablyOnce(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + progress := foreignProgressMaterial("dec-remote-diagnostic-progress", "remote-diagnostic-progress", "valid progress imports beside diagnostic") + progressEnv, err := contract.SyncedEventEnvelopeFromMaterial(progress) + if err != nil { + t.Fatalf("materialize progress: %v", err) + } + remote := staticPullRemoteWorkspace{resp: contract.SyncPullResponse{ + Events: []eventmodel.EventEnvelope{progressEnv}, + Diagnostics: []contract.EventExchangeResult{{ + OriginMnemond: "agent-b", + EventID: "bad-publication-entry", + Subject: eventmodel.Subject("progress_digest", "project"), + Status: "invalid", + Diagnostic: "publication digest mismatch", + }}, + NextCursor: "1", + }} + + if err := syncWorkerPull(rt, remote, "github-sub", nil); err != nil { + t.Fatalf("worker pull with diagnostic: %v", err) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + if content, _ := fields["content"].(string); !strings.Contains(content, "valid progress imports beside diagnostic") { + t.Fatalf("valid pull event must still import:\n%s", content) + } + if got := countRemoteDiagnostics(t, rt, "github-sub", "publication digest mismatch"); got != 2 { + t.Fatalf("remote diagnostic must land as observation + durable diagnostic, got %d matching events", got) + } + + if err := syncWorkerPull(rt, remote, "github-sub", nil); err != nil { + t.Fatalf("repeat worker pull with diagnostic: %v", err) + } + if got := countRemoteDiagnostics(t, rt, "github-sub", "publication digest mismatch"); got != 2 { + t.Fatalf("repeat pull must dedupe remote diagnostic, got %d matching events", got) + } +} diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 201a1b4c..3fc93496 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "os" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/policy" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -58,7 +60,7 @@ func RunSyncWorker(ctx context.Context, rt *runtime.Runtime, opts SyncWorkerOpti } } -// syncWorkerPass runs ONE push+pull pass against the configured current remote. Gate: when +// syncWorkerPass runs ONE sync pass against the configured Remote Workspace plan. Gate: when // remotes.json does not exist, the pass is a no-op — zero sync activity without a connected remote // (I13), checked per pass so `sync connect` takes effect without a restart. func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { @@ -69,24 +71,49 @@ func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { } return fmt.Errorf("stat Remote Workspace config: %w", err) } - entry, err := exchange.LoadRemoteEntry(remotesPath, "default") + plan, err := exchange.LoadRemotePlan(remotesPath, "default") if err != nil { return err } - client, err := syncWorkerClient(entry, opts) - if err != nil { - return err + for _, entry := range plan.PushTargets { + remote, err := syncWorkerRemote(entry, opts) + if err != nil { + return err + } + if err := syncWorkerPush(rt, remote, entry.ID); err != nil { + return err + } } - if err := syncWorkerPush(rt, client, entry.ID); err != nil { - return err + for _, entry := range plan.PullSources { + remote, err := syncWorkerRemote(entry, opts) + if err != nil { + return err + } + if err := syncWorkerPull(rt, remote, entry.ID, opts.Catalog); err != nil { + return err + } + } + return nil +} + +// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. Today only +// the first-party HTTP mnemon-hub backend is implemented; the sync loop above depends only on the +// exchange.RemoteWorkspace ABI so a future GitHub publication mesh does not touch runtime import. +func syncWorkerRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { + switch entry.NormalizedBackend() { + case exchange.RemoteBackendHTTP: + return syncWorkerHTTPRemote(entry, opts) + case exchange.RemoteBackendGitHub: + return syncWorkerGitHubRemote(entry, opts) + default: + return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", entry.ID, entry.NormalizedBackend()) } - return syncWorkerPull(rt, client, entry.ID, opts.Catalog) } -// syncWorkerClient builds the bounded sync client from the remote entry: credential_ref + ca_file -// resolve relative to the project root (the same resolution `sync connect` wrote them under), and -// the endpoint passes the T2 downgrade gate unless explicitly overridden. -func syncWorkerClient(entry exchange.RemoteEntry, opts SyncWorkerOptions) (*access.Client, error) { +// syncWorkerHTTPRemote builds the bounded HTTP mnemon-hub sync client from the remote entry: +// credential_ref + ca_file resolve relative to the project root (the same resolution `sync connect` +// wrote them under), and the endpoint passes the T2 downgrade gate unless explicitly overridden. +func syncWorkerHTTPRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { if strings.TrimSpace(entry.CredentialRef) == "" { return nil, fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) } @@ -114,9 +141,52 @@ func syncWorkerClient(entry exchange.RemoteEntry, opts SyncWorkerOptions) (*acce }) } +func syncWorkerGitHubRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { + token, err := syncWorkerRemoteToken(entry, opts) + if err != nil { + return nil, err + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = access.DefaultSyncTimeout + } + store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ + Repo: entry.Repo, + Token: token, + HTTPClient: &http.Client{Timeout: timeout}, + }) + if err != nil { + return nil, err + } + return githubbackend.New(githubbackend.Config{ + Store: store, + Repo: entry.Repo, + Branch: entry.Branch, + }) +} + +func syncWorkerRemoteToken(entry exchange.RemoteEntry, opts SyncWorkerOptions) (string, error) { + if strings.TrimSpace(entry.CredentialRef) == "" { + return "", fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) + } + tokPath := entry.CredentialRef + if !filepath.IsAbs(tokPath) { + tokPath = filepath.Join(opts.ProjectRoot, tokPath) + } + raw, err := os.ReadFile(tokPath) + if err != nil { + return "", fmt.Errorf("read Remote Workspace token file: %w", err) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", fmt.Errorf("Remote Workspace token file %s is empty", entry.CredentialRef) + } + return token, nil +} + // syncWorkerPush pushes the pending batch (if any) and mirrors the hub's per-event verdicts into // the local ledger — both through the live handle. -func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) error { +func syncWorkerPush(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string) error { batch, err := exchange.ReadPushBatch(rt) if err != nil { return err @@ -124,7 +194,7 @@ func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) if len(batch.Events) == 0 { return nil } - resp, err := client.SyncPush(contract.SyncPushRequest{ + resp, err := remote.SyncPush(contract.SyncPushRequest{ ReplicaID: batch.ReplicaID, BatchID: exchange.PushBatchID(batch.ReplicaID, batch.Events), Events: batch.Events, @@ -138,12 +208,12 @@ func syncWorkerPush(rt *runtime.Runtime, client *access.Client, remoteID string) // syncWorkerPull pulls after the durable cursor, re-enters each event through the live runtime's // trusted intake (importPulledEvents — the same loop the offline path uses), then advances the // cursor. -func syncWorkerPull(rt *runtime.Runtime, client *access.Client, remoteID string, catalog policy.Registry) error { +func syncWorkerPull(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string, catalog policy.Registry) error { state, err := exchange.ReadPullState(rt, remoteID) if err != nil { return err } - resp, err := client.SyncPull(contract.SyncPullRequest{ + resp, err := remote.SyncPull(contract.SyncPullRequest{ ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor, }) @@ -153,5 +223,8 @@ func syncWorkerPull(rt *runtime.Runtime, client *access.Client, remoteID string, if err := importPulledEvents(rt, remoteID, resp.Events, catalog); err != nil { return err } + if err := importRemoteDiagnostics(rt, remoteID, resp.Diagnostics); err != nil { + return err + } return exchange.SetPullCursor(rt, remoteID, resp.NextCursor) } diff --git a/harness/internal/app/sync_worker_test.go b/harness/internal/app/sync_worker_test.go index 8956a35b..6fec9ebc 100644 --- a/harness/internal/app/sync_worker_test.go +++ b/harness/internal/app/sync_worker_test.go @@ -62,6 +62,11 @@ func startHub(t *testing.T, principals map[string]contract.ActorID, scopes []con } func connectRemote(t *testing.T, root, endpoint, token string) { + t.Helper() + connectRemoteWithDirection(t, root, endpoint, token, "") +} + +func connectRemoteWithDirection(t *testing.T, root, endpoint, token, direction string) { t.Helper() credRel := filepath.Join(".mnemon", "harness", "sync", "credentials", "hub.token") credPath := filepath.Join(root, credRel) @@ -72,7 +77,11 @@ func connectRemote(t *testing.T, root, endpoint, token string) { t.Fatal(err) } remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") - doc := fmt.Sprintf(`{"schema_version":1,"current":"hub","remotes":[{"id":"hub","endpoint":%q,"credential_ref":%q}]}`, endpoint, filepath.ToSlash(credRel)) + directionField := "" + if strings.TrimSpace(direction) != "" { + directionField = fmt.Sprintf(`,"direction":%q`, direction) + } + doc := fmt.Sprintf(`{"schema_version":1,"current":"hub","remotes":[{"id":"hub"%s,"endpoint":%q,"credential_ref":%q}]}`, directionField, endpoint, filepath.ToSlash(credRel)) if err := os.WriteFile(remotesPath, []byte(doc+"\n"), 0o600); err != nil { t.Fatal(err) } @@ -239,6 +248,80 @@ func TestSyncWorkerPushPullRoundTrip(t *testing.T) { } } +func TestSyncWorkerPublishOnlyDoesNotPull(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + endpoint, hub, _ := startHub(t, map[string]contract.ActorID{ + "tok-local": "replica-local@team", + "tok-other": "replica-other@team", + }, []contract.ResourceRef{progressRef}) + connectRemoteWithDirection(t, root, endpoint, "tok-local", "publish") + + observeProgress(t, rt, "m-publish-only", "publish-only local progress reaches the hub") + foreign := foreignProgressMaterial("dec-publish-only-foreign", "remote-publish-only", "publish-only must not import this") + if resp, err := hub.Push("replica-other@team", contract.SyncPushRequest{ + ReplicaID: "other-replica", BatchID: "seed-publish-only", Events: testSyncedEvents(t, foreign), + }); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed foreign material: %+v err=%v", resp, err) + } + + if err := syncWorkerPass(rt, SyncWorkerOptions{ProjectRoot: root}); err != nil { + t.Fatalf("publish-only worker pass: %v", err) + } + if pending, _ := rt.PendingSyncedEvents(); len(pending) != 0 { + t.Fatalf("publish-only pass must push local synced events, got %+v", pending) + } + if st, _ := hub.Status("replica-local@team"); st.HubEventsReceived != 2 { + t.Fatalf("publish-only pass must append local event to hub without duplicate work: %+v", st) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + content, _ := fields["content"].(string) + if strings.Contains(content, "publish-only must not import this") { + t.Fatalf("publish-only pass must not pull remote content:\n%s", content) + } +} + +func TestSyncWorkerSubscribeOnlyDoesNotPush(t *testing.T) { + root := t.TempDir() + rt := openServingRuntime(t, root) + progressRef := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + endpoint, hub, _ := startHub(t, map[string]contract.ActorID{ + "tok-local": "replica-local@team", + "tok-other": "replica-other@team", + }, []contract.ResourceRef{progressRef}) + connectRemoteWithDirection(t, root, endpoint, "tok-local", "subscribe") + + observeProgress(t, rt, "m-subscribe-only", "subscribe-only local progress stays pending") + foreign := foreignProgressMaterial("dec-subscribe-only-foreign", "remote-subscribe-only", "subscribe-only imports this") + if resp, err := hub.Push("replica-other@team", contract.SyncPushRequest{ + ReplicaID: "other-replica", BatchID: "seed-subscribe-only", Events: testSyncedEvents(t, foreign), + }); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed foreign material: %+v err=%v", resp, err) + } + + if err := syncWorkerPass(rt, SyncWorkerOptions{ProjectRoot: root}); err != nil { + t.Fatalf("subscribe-only worker pass: %v", err) + } + if pending, _ := rt.PendingSyncedEvents(); len(pending) != 1 { + t.Fatalf("subscribe-only pass must not push local synced events, got %+v", pending) + } + if st, _ := hub.Status("replica-local@team"); st.HubEventsReceived != 1 { + t.Fatalf("subscribe-only pass must not append local event to hub: %+v", st) + } + _, fields, err := rt.Resource(progressRef) + if err != nil { + t.Fatalf("read progress: %v", err) + } + content, _ := fields["content"].(string) + if !strings.Contains(content, "subscribe-only imports this") { + t.Fatalf("subscribe-only pass must pull remote content:\n%s", content) + } +} + // Co-existence proof for the merged policy (v1.1 #2): the serving runtime carries host rules AND // sync-import rules; host-agent flow is unaffected (admission + secret-deny behave exactly as // before), foreign events pass through the principal gates, and the import path works in-process. diff --git a/harness/internal/coreguard/github_remote_workspace_guard_test.go b/harness/internal/coreguard/github_remote_workspace_guard_test.go new file mode 100644 index 00000000..ccc45a2d --- /dev/null +++ b/harness/internal/coreguard/github_remote_workspace_guard_test.go @@ -0,0 +1,200 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var githubRemoteWorkspaceCorePackages = []string{ + "runtime", + "mnemond/state", + "mnemond/admission", + "mnemond/presentation", + "hostagent", +} + +var forbiddenGitHubBackendTerms = []string{ + "github issue", + "github issues", + "github pr", + "github pull request", + "github action", + "github actions", + "issue-based", + "pr-based", + "action-based", + "p2p discovery", + "peer discovery", + "node discovery", + "gossip", + "dht", + "routing table", + "nat traversal", + "overlay network", +} + +func TestGitHubRemoteWorkspaceGuardLogicIsNotVacuous(t *testing.T) { + if !isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github") { + t.Fatal("GitHub backend import matcher must flag the planned exchange/backend/github package") + } + if isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange") { + t.Fatal("plain exchange package must not be treated as GitHub backend") + } + if !isAllowedGitHubBackendDir(filepath.FromSlash("mnemonhub/exchange/backend/github")) { + t.Fatal("planned GitHub backend directory should be allowed under mnemonhub/exchange/backend") + } + if isAllowedGitHubBackendDir(filepath.FromSlash("runtime/github")) { + t.Fatal("GitHub backend outside mnemonhub/exchange/backend must not be allowed") + } + if !hasForbiddenGitHubBackendTerm("use GitHub Issues as assignments") { + t.Fatal("forbidden GitHub teamwork semantic matcher should flag Issue/PR/Actions concepts") + } + if hasForbiddenGitHubBackendTerm("publication branch enumeration") { + t.Fatal("publication terminology should remain allowed") + } +} + +func TestGitHubRemoteWorkspaceBackendDoesNotLeakIntoCore(t *testing.T) { + for _, pkg := range githubRemoteWorkspaceCorePackages { + _, files := packageFiles(t, pkg) + for _, file := range files { + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if isGitHubRemoteBackendImportPath(path) { + t.Errorf("core package %q imports GitHub Remote Workspace backend %q; GitHub must stay below mnemonhub/exchange/backend", pkg, path) + } + } + } + } +} + +func TestGitHubRemoteWorkspaceBackendLocationAndNaming(t *testing.T) { + root := filepath.Clean("..") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + relFile, err := filepath.Rel(root, path) + if err != nil { + return err + } + relDir := filepath.Dir(relFile) + if pathMentionsGitHubBackend(relDir) && !isAllowedGitHubBackendDir(relDir) { + t.Errorf("GitHub backend source %s is outside mnemonhub/exchange/backend", relFile) + } + if isAllowedGitHubBackendDir(relDir) { + assertGitHubBackendFileUsesPublicationVocabulary(t, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk harness/internal: %v", err) + } +} + +func isGitHubRemoteBackendImportPath(path string) bool { + rel := strings.TrimPrefix(path, "github.com/mnemon-dev/mnemon/") + if !strings.HasPrefix(rel, "harness/internal/mnemonhub/exchange/") { + return false + } + tail := strings.TrimPrefix(rel, "harness/internal/mnemonhub/exchange/") + for _, segment := range strings.Split(tail, "/") { + if strings.Contains(strings.ToLower(segment), "github") { + return true + } + } + return false +} + +func isAllowedGitHubBackendDir(relDir string) bool { + clean := filepath.ToSlash(filepath.Clean(relDir)) + return clean == "mnemonhub/exchange/backend" || + strings.HasPrefix(clean, "mnemonhub/exchange/backend/") +} + +func pathMentionsGitHubBackend(relDir string) bool { + for _, segment := range strings.Split(filepath.ToSlash(relDir), "/") { + if strings.Contains(strings.ToLower(segment), "github") { + return true + } + } + return false +} + +func assertGitHubBackendFileUsesPublicationVocabulary(t *testing.T, path string) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.Ident: + if hasForbiddenGitHubBackendTerm(splitIdentifierWords(n.Name)) { + t.Errorf("%s uses forbidden GitHub/P2P backend term %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), n.Name) + } + case *ast.BasicLit: + if n.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(n.Value) + if err != nil { + value = strings.Trim(n.Value, "`\"") + } + if hasForbiddenGitHubBackendTerm(value) { + t.Errorf("%s uses forbidden GitHub/P2P backend literal %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), value) + } + } + return true + }) +} + +func splitIdentifierWords(name string) string { + var b strings.Builder + var prevLower bool + for _, r := range name { + if r == '_' || r == '-' { + b.WriteByte(' ') + prevLower = false + continue + } + if r >= 'A' && r <= 'Z' { + if prevLower { + b.WriteByte(' ') + } + b.WriteRune(r + ('a' - 'A')) + prevLower = false + continue + } + b.WriteRune(r) + prevLower = r >= 'a' && r <= 'z' + } + return b.String() +} + +func hasForbiddenGitHubBackendTerm(text string) bool { + normalized := strings.ToLower(strings.Join(strings.Fields(strings.ReplaceAll(text, "_", " ")), " ")) + for _, term := range forbiddenGitHubBackendTerms { + if strings.Contains(normalized, term) { + return true + } + } + return false +} diff --git a/harness/internal/mnemond/policy/sync_import.go b/harness/internal/mnemond/policy/sync_import.go index a09ffb9c..1640f3a2 100644 --- a/harness/internal/mnemond/policy/sync_import.go +++ b/harness/internal/mnemond/policy/sync_import.go @@ -94,6 +94,11 @@ func sortedImportable(catalog Registry) []EventPackage { // origin_replica_id, local_decision_id, remote_id}. const SyncImportSkippedObserved = "sync.import_skipped.observed" +// SyncRemoteDiagnosticObserved is the observation a sync puller ingests when a Remote Workspace +// returns a pull-side diagnostic for an invalid/rejected/conflicting publication entry. Payload: +// {remote_id, origin_mnemond, event_id, subject, status, diagnostic}. +const SyncRemoteDiagnosticObserved = "sync.remote_diagnostic.observed" + // SyncImportSkippedRule is the legal diagnostic mechanism for skipped kinds: it Handles ONLY the // skipped observation, gates on the sync import principal (foreign events pass through), and always // denies with a reason naming the kind — the deny is what produces the durable *.diagnostic (S7); @@ -114,3 +119,26 @@ func SyncImportSkippedRule(principal contract.ActorID) admission.Rule { }, nil }) } + +// SyncRemoteDiagnosticRule is the legal diagnostic mechanism for pull-side Remote Workspace +// diagnostics. Like skipped-kind import, it denies a sync.* observation so the kernel emits one +// durable sync.diagnostic with lineage to the original remote diagnostic observation. +func SyncRemoteDiagnosticRule(principal contract.ActorID) admission.Rule { + return admission.NewNativeRule("sync-remote-diagnostic:"+string(principal), principal, "", []string{SyncRemoteDiagnosticObserved}, + func(in admission.RuleInput) (contract.RuleDecision, error) { + if in.Event.Actor != principal { + return contract.RuleDecision{Verdict: contract.VerdictAllow}, nil + } + remoteID, _ := in.Event.Payload["remote_id"].(string) + status, _ := in.Event.Payload["status"].(string) + origin, _ := in.Event.Payload["origin_mnemond"].(string) + eventID, _ := in.Event.Payload["event_id"].(string) + subject, _ := in.Event.Payload["subject"].(string) + diagnostic, _ := in.Event.Payload["diagnostic"].(string) + return contract.RuleDecision{ + Verdict: contract.VerdictDeny, + Reasons: []string{fmt.Sprintf("remote workspace diagnostic: remote_id=%q status=%q origin_mnemond=%q event_id=%q subject=%q: %s", + remoteID, status, origin, eventID, subject, diagnostic)}, + }, nil + }) +} diff --git a/harness/internal/mnemond/policy/sync_import_test.go b/harness/internal/mnemond/policy/sync_import_test.go index e8948e33..42eee2ab 100644 --- a/harness/internal/mnemond/policy/sync_import_test.go +++ b/harness/internal/mnemond/policy/sync_import_test.go @@ -32,6 +32,38 @@ func TestSyncImportSkippedRuleDeniesNamingKind(t *testing.T) { } } +func TestSyncRemoteDiagnosticRuleDeniesNamingRemoteDiagnostic(t *testing.T) { + r := SyncRemoteDiagnosticRule(contract.SyncImportActor) + if r.Handles("fixture_record.write_candidate.observed") || !r.Handles(SyncRemoteDiagnosticObserved) { + t.Fatal("rule must handle exactly the remote diagnostic observation type") + } + dec, err := r.Evaluate(admission.RuleInput{Event: contract.Event{ + Type: SyncRemoteDiagnosticObserved, + Actor: contract.SyncImportActor, + Payload: map[string]any{ + "remote_id": "github-sub", + "origin_mnemond": "agent-b", + "event_id": "evt-bad", + "subject": "progress_digest/project", + "status": "invalid", + "diagnostic": "digest mismatch", + }, + }}) + if err != nil { + t.Fatal(err) + } + if dec.Verdict != contract.VerdictDeny || len(dec.Reasons) != 1 || + !strings.Contains(dec.Reasons[0], `"github-sub"`) || + !strings.Contains(dec.Reasons[0], `"invalid"`) || + !strings.Contains(dec.Reasons[0], "digest mismatch") { + t.Fatalf("remote diagnostic must deny naming remote/status/reason, got %+v", dec) + } + foreign, err := r.Evaluate(admission.RuleInput{Event: contract.Event{Type: SyncRemoteDiagnosticObserved, Actor: "someone@else"}}) + if err != nil || foreign.Verdict != contract.VerdictAllow { + t.Fatalf("a foreign principal's event must pass through, got %+v err=%v", foreign, err) + } +} + // The embedded importable set is descriptor-derived (PD6, replacing the former hardcoded // contract.SyncableResourceKinds): the embedded catalog opts each syncable kind into Remote // Workspace import under its declared closed-set merge strategy. This is the pin the deleted diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go new file mode 100644 index 00000000..e706aecc --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/backend.go @@ -0,0 +1,232 @@ +package githubbackend + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +type Config struct { + Store exchange.PublicationStore + Repo string + Branch string + Scopes []contract.ResourceRef +} + +type Backend struct { + store exchange.PublicationStore + repo string + branch string + scopes []contract.ResourceRef +} + +func New(cfg Config) (*Backend, error) { + if cfg.Store == nil { + return nil, fmt.Errorf("publication store is required") + } + repo := strings.TrimSpace(cfg.Repo) + if repo == "" { + return nil, fmt.Errorf("publication repo is required") + } + branch, err := exchange.NormalizePublicationBranch(cfg.Branch) + if err != nil { + return nil, err + } + return &Backend{ + store: cfg.Store, + repo: repo, + branch: branch, + scopes: append([]contract.ResourceRef(nil), cfg.Scopes...), + }, nil +} + +func (b *Backend) SyncPush(req contract.SyncPushRequest) (contract.SyncPushResponse, error) { + replicaID := strings.TrimSpace(req.ReplicaID) + if replicaID == "" { + return contract.SyncPushResponse{}, fmt.Errorf("sync push requires replica_id") + } + var resp contract.SyncPushResponse + for _, env := range req.Events { + material, diagnostic := b.syncedEventMaterial(env) + if diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + if material.OriginReplicaID != replicaID { + return contract.SyncPushResponse{}, fmt.Errorf("sync push replica_id %q does not match event origin %q", replicaID, material.OriginReplicaID) + } + if diagnostic := validateSyncedMaterial(material); diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + if diagnostic := b.scopeDiagnostic(material.ResourceRef); diagnostic != "" { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) + continue + } + path, err := exchange.PublicationEventPath(env) + if err != nil { + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", err.Error())) + continue + } + body, err := json.Marshal(env) + if err != nil { + return contract.SyncPushResponse{}, err + } + put, err := b.store.PutEvent(context.Background(), b.branch, path, body) + if err != nil { + return contract.SyncPushResponse{}, err + } + switch { + case put.Created, put.ExistsSame: + resp.Accepted = append(resp.Accepted, eventExchangeResult(env, "accepted", "")) + case put.Conflict: + resp.Conflicts = append(resp.Conflicts, eventExchangeResult(env, "conflict", "publication event path already exists with different content")) + default: + resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", "publication store returned no put verdict")) + } + } + return resp, nil +} + +func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullResponse, error) { + replicaID := strings.TrimSpace(req.ReplicaID) + if replicaID == "" { + return contract.SyncPullResponse{}, fmt.Errorf("sync pull requires replica_id") + } + scopes := append([]contract.ResourceRef(nil), req.Scopes...) + if len(b.scopes) > 0 { + var err error + scopes, err = contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) + if err != nil { + return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) + } + } + list, err := b.store.ListEvents(context.Background(), b.branch, exchange.PublicationEventRoot, req.RemoteCursor) + if err != nil { + return contract.SyncPullResponse{}, err + } + resp := contract.SyncPullResponse{NextCursor: localStateCursor(req.RemoteCursor, list.NextCursor)} + for _, stored := range list.Events { + env, result, ok := decodeStoredEvent(stored) + if !ok { + resp.Diagnostics = append(resp.Diagnostics, result) + continue + } + material, diagnostic := b.syncedEventMaterial(env) + if diagnostic != "" { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) + continue + } + if material.OriginReplicaID == replicaID { + continue + } + if diagnostic := validateSyncedMaterial(material); diagnostic != "" { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) + continue + } + if len(scopes) > 0 && !refAllowed(scopes, material.ResourceRef) { + resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "rejected", fmt.Sprintf("ref %s/%s is outside configured publication scope", material.ResourceRef.Kind, material.ResourceRef.ID))) + continue + } + resp.Events = append(resp.Events, env) + } + return resp, nil +} + +func localStateCursor(previous, storeCursor string) string { + storeCursor = strings.TrimSpace(storeCursor) + if storeCursor == "" { + return "" + } + if _, err := strconv.ParseInt(storeCursor, 10, 64); err == nil { + return storeCursor + } + previous = strings.TrimSpace(previous) + if _, err := strconv.ParseInt(previous, 10, 64); err == nil && previous != "" { + return previous + } + return "1" +} + +func (b *Backend) SyncStatus() (contract.SyncStatusResponse, error) { + return contract.SyncStatusResponse{RemoteWorkspace: b.repo + ":" + b.branch}, nil +} + +func (b *Backend) syncedEventMaterial(env eventmodel.EventEnvelope) (contract.SyncedEventMaterial, string) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return contract.SyncedEventMaterial{}, err.Error() + } + return material, "" +} + +func (b *Backend) scopeDiagnostic(ref contract.ResourceRef) string { + if len(b.scopes) == 0 || refAllowed(b.scopes, ref) { + return "" + } + return fmt.Sprintf("ref %s/%s is outside configured publication scope", ref.Kind, ref.ID) +} + +func validateSyncedMaterial(material contract.SyncedEventMaterial) string { + switch { + case strings.TrimSpace(material.OriginReplicaID) == "": + return "origin_replica_id is required" + case strings.TrimSpace(material.LocalDecisionID) == "": + return "local_decision_id is required" + case strings.TrimSpace(string(material.Actor)) == "": + return "actor is required" + case strings.TrimSpace(string(material.ResourceRef.Kind)) == "" || strings.TrimSpace(string(material.ResourceRef.ID)) == "": + return "resource_ref is required" + case material.Fields == nil: + return "fields are required" + case strings.TrimSpace(material.FieldsDigest) == "": + return "fields_digest is required" + case material.FieldsDigest != syncedFieldsDigest(material.Fields): + return "fields_digest does not match fields" + default: + return "" + } +} + +func decodeStoredEvent(stored exchange.PublicationStoredEvent) (eventmodel.EventEnvelope, contract.EventExchangeResult, bool) { + var env eventmodel.EventEnvelope + if err := json.Unmarshal(stored.Body, &env); err != nil { + return eventmodel.EventEnvelope{}, contract.EventExchangeResult{ + EventID: stored.Path, + Status: "invalid", + Diagnostic: "publication event json is invalid: " + err.Error(), + }, false + } + return env, contract.EventExchangeResult{}, true +} + +func eventExchangeResult(env eventmodel.EventEnvelope, status, diagnostic string) contract.EventExchangeResult { + result := contract.EventExchangeResultFromEnvelope(env, status, diagnostic) + if strings.TrimSpace(result.EventID) == "" { + result.EventID = env.Event.ID + } + return result +} + +func refAllowed(scopes []contract.ResourceRef, ref contract.ResourceRef) bool { + for _, scope := range scopes { + if scope == ref { + return true + } + } + return false +} + +func syncedFieldsDigest(fields map[string]any) string { + b, _ := json.Marshal(fields) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go new file mode 100644 index 00000000..2dc2e7ca --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go @@ -0,0 +1,246 @@ +package githubbackend + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +var progressRef = contract.ResourceRef{Kind: "progress_digest", ID: "project"} + +func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "published progress"}) + + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("sync push: %v", err) + } + if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { + t.Fatalf("push resp = %+v, want one accepted", resp) + } + list, err := store.ListEvents(context.Background(), "mnemon/mnemond-a", exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("list publication events: %v", err) + } + if len(list.Events) != 1 { + t.Fatalf("publication store events = %+v, want one", list.Events) + } + + replayed, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("repeat sync push: %v", err) + } + if len(replayed.Accepted) != 1 || len(replayed.Conflicts) != 0 { + t.Fatalf("repeat push must be idempotent accepted, got %+v", replayed) + } +} + +func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { + _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "bad phase"}) + env.Phase = eventmodel.PhaseAccepted + + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + t.Fatalf("sync push invalid phase: %v", err) + } + if len(resp.Rejected) != 1 || !strings.Contains(resp.Rejected[0].Diagnostic, "phase") { + t.Fatalf("invalid phase must be rejected with diagnostic, got %+v", resp) + } +} + +func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { + _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) + env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "same key"}) + if resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed push: resp=%+v err=%v", resp, err) + } + changedBody := env + changedBody.Event.CorrelationID = "changed-body" + resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-b", Events: []eventmodel.EventEnvelope{changedBody}}) + if err != nil { + t.Fatalf("conflict push: %v", err) + } + if len(resp.Conflicts) != 1 || !strings.Contains(resp.Conflicts[0].Diagnostic, "different content") { + t.Fatalf("same key different body must conflict, got %+v", resp) + } +} + +func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) + foreign := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) + own := githubBackendTestEnvelope(t, "replica-a", "dec-own", progressRef, map[string]any{"content": "own progress"}) + putStoredEvent(t, store, "mnemon/mnemond-b", foreign) + putStoredEvent(t, store, "mnemon/mnemond-b", own) + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull: %v", err) + } + if len(resp.Events) != 1 || resp.Events[0].Event.ID != foreign.Event.ID || len(resp.Diagnostics) != 0 || resp.NextCursor != "2" { + t.Fatalf("pull resp = %+v, want only foreign event and cursor 2", resp) + } + again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("sync pull after cursor: %v", err) + } + if len(again.Events) != 0 || len(again.Diagnostics) != 0 || again.NextCursor != "2" { + t.Fatalf("cursor pull must be empty at cursor 2, got %+v", again) + } +} + +func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { + store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) + invalidPhase := githubBackendTestEnvelope(t, "replica-b", "dec-invalid-phase", progressRef, map[string]any{"content": "invalid phase"}) + invalidPhase.Phase = eventmodel.PhaseAccepted + badDigest := githubBackendTestEnvelope(t, "replica-b", "dec-bad-digest", progressRef, map[string]any{"content": "bad digest"}) + badDigest.Meta["digest"] = "wrong" + outOfScope := githubBackendTestEnvelope(t, "replica-b", "dec-out-scope", contract.ResourceRef{Kind: "assignment", ID: "project"}, map[string]any{"content": "assignment"}) + putStoredEvent(t, store, "mnemon/mnemond-b", invalidPhase) + putStoredEvent(t, store, "mnemon/mnemond-b", badDigest) + putStoredEvent(t, store, "mnemon/mnemond-b", outOfScope) + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull diagnostics: %v", err) + } + if len(resp.Events) != 0 || len(resp.Diagnostics) != 3 { + t.Fatalf("pull diagnostics resp = %+v, want three diagnostics", resp) + } + joined := diagnosticsText(resp.Diagnostics) + for _, want := range []string{"phase", "fields_digest", "outside configured publication scope"} { + if !strings.Contains(joined, want) { + t.Fatalf("diagnostics missing %q in %s", want, joined) + } + } +} + +func TestGitHubBackendPullNormalizesOpaquePublicationCursor(t *testing.T) { + env := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) + body, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + store := opaqueCursorStore{ + events: []exchange.PublicationStoredEvent{{ + Path: "events/replica-b/foreign.json", + Body: body, + Cursor: "head-abc", + }}, + } + backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/mnemond-b", Scopes: []contract.ResourceRef{progressRef}}) + if err != nil { + t.Fatal(err) + } + + resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) + if err != nil { + t.Fatalf("sync pull: %v", err) + } + if resp.NextCursor != "1" { + t.Fatalf("opaque publication cursor must normalize to local numeric cursor, got %q", resp.NextCursor) + } + if len(resp.Events) != 1 || resp.Events[0].Event.ID != env.Event.ID { + t.Fatalf("sync pull events = %+v, want foreign event", resp.Events) + } + + again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("sync pull with local cursor: %v", err) + } + if again.NextCursor != resp.NextCursor || len(again.Events) != 1 { + t.Fatalf("opaque cursor pull should keep local cursor and rely on import idempotency, got %+v", again) + } +} + +func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) (*exchange.MemoryPublicationStore, *Backend) { + t.Helper() + store, err := exchange.NewMemoryPublicationStore(branch) + if err != nil { + t.Fatal(err) + } + backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: branch, Scopes: scopes}) + if err != nil { + t.Fatal(err) + } + return store, backend +} + +type opaqueCursorStore struct { + events []exchange.PublicationStoredEvent +} + +func (s opaqueCursorStore) PutEvent(context.Context, string, string, []byte) (exchange.PublicationPutResult, error) { + return exchange.PublicationPutResult{}, nil +} + +func (s opaqueCursorStore) ListEvents(context.Context, string, string, string) (exchange.PublicationListResult, error) { + return exchange.PublicationListResult{Events: append([]exchange.PublicationStoredEvent(nil), s.events...), NextCursor: "head-abc"}, nil +} + +func (s opaqueCursorStore) ReadFile(context.Context, string, string) ([]byte, error) { + return nil, nil +} + +func (s opaqueCursorStore) WriteFile(context.Context, string, string, []byte) error { + return nil +} + +func putStoredEvent(t *testing.T, store *exchange.MemoryPublicationStore, branch string, env eventmodel.EventEnvelope) { + t.Helper() + path, err := exchange.PublicationEventPath(githubBackendPathEnvelope(env)) + if err != nil { + t.Fatalf("publication path: %v", err) + } + body, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(context.Background(), branch, path, body); err != nil { + t.Fatalf("put stored event: %v", err) + } +} + +func githubBackendPathEnvelope(env eventmodel.EventEnvelope) eventmodel.EventEnvelope { + if env.Phase == eventmodel.PhaseSynced { + return env + } + out := env + out.Phase = eventmodel.PhaseSynced + return out +} + +func githubBackendTestEnvelope(t *testing.T, origin, decisionID string, ref contract.ResourceRef, fields map[string]any) eventmodel.EventEnvelope { + t.Helper() + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: origin, + LocalDecisionID: decisionID, + LocalIngestSeq: 7, + Actor: "codex@project", + ResourceRef: ref, + ResourceVersion: 1, + FieldsDigest: syncedFieldsDigest(fields), + Fields: fields, + DecidedAt: "2026-06-12T00:00:00Z", + Status: "pending", + }) + if err != nil { + t.Fatalf("synced event envelope: %v", err) + } + return env +} + +func diagnosticsText(items []contract.EventExchangeResult) string { + var b strings.Builder + for _, item := range items { + b.WriteString(item.Diagnostic) + b.WriteByte('\n') + } + return b.String() +} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go new file mode 100644 index 00000000..5faf23e0 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -0,0 +1,562 @@ +package githubbackend + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + pathpkg "path" + "sort" + "strings" + "sync" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +const githubAPIVersion = "2022-11-28" + +type PublicationStoreConfig struct { + Repo string + Token string + BaseURL string + UserAgent string + HTTPClient *http.Client + MutativeDelay time.Duration +} + +type GitHubPublicationStore struct { + owner string + repo string + token string + baseURL string + userAgent string + client *http.Client + mutativeDelay time.Duration + writeMu sync.Mutex + lastWrite time.Time +} + +func NewPublicationStore(cfg PublicationStoreConfig) (*GitHubPublicationStore, error) { + repo, err := exchange.NormalizeGitHubRepo(cfg.Repo) + if err != nil { + return nil, err + } + owner, name, _ := strings.Cut(repo, "/") + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + baseURL = "https://api.github.com" + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + userAgent := strings.TrimSpace(cfg.UserAgent) + if userAgent == "" { + userAgent = "mnemon-harness" + } + return &GitHubPublicationStore{ + owner: owner, + repo: name, + token: strings.TrimSpace(cfg.Token), + baseURL: baseURL, + userAgent: userAgent, + client: client, + mutativeDelay: cfg.MutativeDelay, + }, nil +} + +func (s *GitHubPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (exchange.PublicationPutResult, error) { + branch, path, err := normalizeGitHubPublicationEventRef(branch, path) + if err != nil { + return exchange.PublicationPutResult{}, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + existing, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return exchange.PublicationPutResult{}, err + } + if found { + if bytes.Equal(existing.body, body) { + return exchange.PublicationPutResult{ExistsSame: true}, nil + } + return exchange.PublicationPutResult{Conflict: true}, nil + } + if err := s.pauseBeforeMutation(ctx); err != nil { + return exchange.PublicationPutResult{}, err + } + if err := s.putFile(ctx, branch, path, body, ""); err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusConflict { + return exchange.PublicationPutResult{Conflict: true}, nil + } + return exchange.PublicationPutResult{}, err + } + s.lastWrite = time.Now() + return exchange.PublicationPutResult{Created: true}, nil +} + +func (s *GitHubPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (exchange.PublicationListResult, error) { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return exchange.PublicationListResult{}, err + } + prefix, err = normalizeGitHubPublicationEventPrefix(prefix) + if err != nil { + return exchange.PublicationListResult{}, err + } + head, err := s.branchHead(ctx, branch) + if err != nil { + return exchange.PublicationListResult{}, err + } + if strings.TrimSpace(cursor) == head { + return exchange.PublicationListResult{NextCursor: head}, nil + } + paths, err := s.listEventPaths(ctx, branch, prefix) + if err != nil { + return exchange.PublicationListResult{}, err + } + events := make([]exchange.PublicationStoredEvent, 0, len(paths)) + for _, path := range paths { + file, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return exchange.PublicationListResult{}, err + } + if !found { + continue + } + events = append(events, exchange.PublicationStoredEvent{Path: path, Body: file.body, Cursor: head}) + } + return exchange.PublicationListResult{Events: events, NextCursor: head}, nil +} + +func (s *GitHubPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return nil, err + } + file, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("publication file %s:%s not found", branch, path) + } + return append([]byte(nil), file.body...), nil +} + +func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return err + } + if strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { + return fmt.Errorf("publication event path %q must be written with PutEvent", path) + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + existing, found, err := s.readFileWithSHA(ctx, branch, path) + if err != nil { + return err + } + sha := "" + if found { + sha = existing.sha + } + if err := s.pauseBeforeMutation(ctx); err != nil { + return err + } + if err := s.putFile(ctx, branch, path, body, sha); err != nil { + return err + } + s.lastWrite = time.Now() + return nil +} + +func (s *GitHubPublicationStore) EnsureBranch(ctx context.Context, branch string, baseBranch string) error { + return s.EnsureBranches(ctx, []string{branch}, baseBranch) +} + +func (s *GitHubPublicationStore) EnsureBranches(ctx context.Context, branches []string, baseBranch string) error { + baseBranch, err := normalizeGitHubBranchName(baseBranch) + if err != nil { + return fmt.Errorf("base branch: %w", err) + } + if baseBranch == "" { + baseBranch = "main" + } + normalized := make([]string, 0, len(branches)) + seen := map[string]bool{} + for _, branch := range branches { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return err + } + if seen[branch] { + continue + } + seen[branch] = true + normalized = append(normalized, branch) + } + if len(normalized) == 0 { + return nil + } + var missing []string + for _, branch := range normalized { + if _, err := s.branchHead(ctx, branch); err == nil { + continue + } else if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + missing = append(missing, branch) + continue + } else { + return err + } + } + if len(missing) == 0 { + return nil + } + baseSHA, err := s.branchHead(ctx, baseBranch) + if err != nil { + return fmt.Errorf("read base branch %q: %w", baseBranch, err) + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + for _, branch := range missing { + if err := s.pauseBeforeMutation(ctx); err != nil { + return err + } + req := githubCreateRefRequest{ + Ref: "refs/heads/" + branch, + SHA: baseSHA, + } + status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { + if _, headErr := s.branchHead(ctx, branch); headErr == nil { + continue + } + } + return fmt.Errorf("create branch %q: %w", branch, err) + } + if status != http.StatusCreated { + return fmt.Errorf("create branch %q returned status %d", branch, status) + } + s.lastWrite = time.Now() + } + return nil +} + +type githubFile struct { + body []byte + sha string +} + +type githubContentResponse struct { + Type string `json:"type"` + Path string `json:"path"` + SHA string `json:"sha"` + Encoding string `json:"encoding"` + Content string `json:"content"` +} + +type githubContentEntry struct { + Type string `json:"type"` + Path string `json:"path"` +} + +type githubPutFileRequest struct { + Message string `json:"message"` + Content string `json:"content"` + SHA string `json:"sha,omitempty"` + Branch string `json:"branch"` +} + +type githubCreateRefRequest struct { + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +type githubRefResponse struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` +} + +type githubAPIError struct { + Status int + Message string + RetryAfter string + RateLimitRemaining string + RateLimitReset string +} + +func (e *githubAPIError) Error() string { + base := "" + if strings.TrimSpace(e.Message) == "" { + base = fmt.Sprintf("github api status %d", e.Status) + } else { + base = fmt.Sprintf("github api status %d: %s", e.Status, e.Message) + } + var hints []string + if strings.TrimSpace(e.RetryAfter) != "" { + hints = append(hints, "retry_after="+strings.TrimSpace(e.RetryAfter)) + } + if strings.TrimSpace(e.RateLimitRemaining) != "" { + hints = append(hints, "rate_limit_remaining="+strings.TrimSpace(e.RateLimitRemaining)) + } + if strings.TrimSpace(e.RateLimitReset) != "" { + hints = append(hints, "rate_limit_reset="+strings.TrimSpace(e.RateLimitReset)) + } + if len(hints) == 0 { + return base + } + return base + " (" + strings.Join(hints, ", ") + ")" +} + +func (s *GitHubPublicationStore) readFileWithSHA(ctx context.Context, branch, path string) (githubFile, bool, error) { + var out githubContentResponse + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &out) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + return githubFile{}, false, nil + } + return githubFile{}, false, err + } + if status != http.StatusOK { + return githubFile{}, false, fmt.Errorf("github content read returned status %d", status) + } + body, err := decodeGitHubContent(out) + if err != nil { + return githubFile{}, false, err + } + return githubFile{body: body, sha: out.SHA}, true, nil +} + +func (s *GitHubPublicationStore) putFile(ctx context.Context, branch, path string, body []byte, sha string) error { + req := githubPutFileRequest{ + Message: "mnemon publication update " + path, + Content: base64.StdEncoding.EncodeToString(body), + SHA: sha, + Branch: branch, + } + status, err := s.do(ctx, http.MethodPut, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), nil, req, nil) + if err != nil { + return err + } + if status != http.StatusOK && status != http.StatusCreated { + return fmt.Errorf("github content write returned status %d", status) + } + return nil +} + +func (s *GitHubPublicationStore) branchHead(ctx context.Context, branch string) (string, error) { + var out githubRefResponse + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/git/ref/heads/"+escapeGitHubPath(branch), nil, nil, &out) + if err != nil { + return "", err + } + if status != http.StatusOK || strings.TrimSpace(out.Object.SHA) == "" { + return "", fmt.Errorf("github branch ref %q returned status %d without sha", branch, status) + } + return strings.TrimSpace(out.Object.SHA), nil +} + +func (s *GitHubPublicationStore) listEventPaths(ctx context.Context, branch, prefix string) ([]string, error) { + entries, err := s.listDir(ctx, branch, prefix) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { + return nil, nil + } + return nil, err + } + var paths []string + for _, entry := range entries { + switch entry.Type { + case "dir": + nested, err := s.listEventPaths(ctx, branch, entry.Path) + if err != nil { + return nil, err + } + paths = append(paths, nested...) + case "file": + if strings.HasPrefix(entry.Path, prefix) { + paths = append(paths, entry.Path) + } + } + } + sort.Strings(paths) + return paths, nil +} + +func (s *GitHubPublicationStore) listDir(ctx context.Context, branch, path string) ([]githubContentEntry, error) { + var entries []githubContentEntry + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &entries) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("github directory list returned status %d", status) + } + return entries, nil +} + +func (s *GitHubPublicationStore) do(ctx context.Context, method, apiPath string, query url.Values, in any, out any) (int, error) { + var body io.Reader + if in != nil { + data, err := json.Marshal(in) + if err != nil { + return 0, err + } + body = bytes.NewReader(data) + } + u := s.baseURL + apiPath + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) + req.Header.Set("User-Agent", s.userAgent) + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + resp, err := s.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, err + } + if resp.StatusCode >= 300 { + var apiErr struct { + Message string `json:"message"` + } + _ = json.Unmarshal(data, &apiErr) + return resp.StatusCode, &githubAPIError{ + Status: resp.StatusCode, + Message: apiErr.Message, + RetryAfter: resp.Header.Get("Retry-After"), + RateLimitRemaining: resp.Header.Get("X-RateLimit-Remaining"), + RateLimitReset: resp.Header.Get("X-RateLimit-Reset"), + } + } + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, err + } + } + return resp.StatusCode, nil +} + +func (s *GitHubPublicationStore) pauseBeforeMutation(ctx context.Context) error { + if s.mutativeDelay <= 0 || s.lastWrite.IsZero() { + return nil + } + wait := s.mutativeDelay - time.Since(s.lastWrite) + if wait <= 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func decodeGitHubContent(resp githubContentResponse) ([]byte, error) { + if resp.Type != "" && resp.Type != "file" { + return nil, fmt.Errorf("github content %q is not a file", resp.Path) + } + if resp.Encoding != "base64" { + return nil, fmt.Errorf("github content %q has unsupported encoding %q", resp.Path, resp.Encoding) + } + content := strings.ReplaceAll(resp.Content, "\n", "") + body, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return nil, err + } + return body, nil +} + +func normalizeGitHubPublicationEventRef(branch, path string) (string, string, error) { + branch, path, err := normalizeGitHubPublicationFileRef(branch, path) + if err != nil { + return "", "", err + } + if !strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { + return "", "", fmt.Errorf("publication event path %q must be under %s", path, exchange.PublicationEventRoot) + } + return branch, path, nil +} + +func normalizeGitHubPublicationFileRef(branch, path string) (string, string, error) { + branch, err := exchange.NormalizePublicationBranch(branch) + if err != nil { + return "", "", err + } + path, err = exchange.NormalizePublicationPath(path) + if err != nil { + return "", "", err + } + return branch, path, nil +} + +func normalizeGitHubBranchName(branch string) (string, error) { + branch = strings.TrimSpace(branch) + if branch == "" { + return "", nil + } + if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { + return "", fmt.Errorf("branch %q is invalid", branch) + } + for _, part := range strings.Split(branch, "/") { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("branch %q is invalid", branch) + } + } + if pathpkg.Clean(branch) != branch { + return "", fmt.Errorf("branch %q is invalid", branch) + } + return branch, nil +} + +func normalizeGitHubPublicationEventPrefix(prefix string) (string, error) { + prefix, err := exchange.NormalizePublicationPath(prefix) + if err != nil { + return "", err + } + if prefix == exchange.PublicationEventRoot { + return prefix, nil + } + if !strings.HasPrefix(prefix, exchange.PublicationEventRoot+"/") { + return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, exchange.PublicationEventRoot) + } + return prefix, nil +} + +func escapeGitHubPath(path string) string { + clean := pathpkg.Clean(strings.Trim(path, "/")) + if clean == "." { + return "" + } + parts := strings.Split(clean, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go new file mode 100644 index 00000000..98b86b90 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -0,0 +1,406 @@ +package githubbackend + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + Token: "secret-token", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + path := exchange.PublicationEventRoot + "/replica-a/progress_digest/project/000000000001-dec-a.json" + + first, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("put event create: %v", err) + } + if !first.Created || fake.puts != 1 { + t.Fatalf("first put = %+v, puts=%d; want created with one PUT", first, fake.puts) + } + same, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("put event same: %v", err) + } + if !same.ExistsSame || same.Conflict || fake.puts != 1 { + t.Fatalf("same put = %+v puts=%d; want idempotent without PUT", same, fake.puts) + } + conflict, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"b"}`)) + if err != nil { + t.Fatalf("put event conflict: %v", err) + } + if !conflict.Conflict || fake.puts != 1 { + t.Fatalf("conflict put = %+v puts=%d; want conflict without overwrite", conflict, fake.puts) + } +} + +func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.files["mnemon/team:.mnemon/team.json"] = fakeGitHubFile{body: []byte(`{"schema_version":1}`), sha: "sha-existing"} + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + if err := store.WriteFile(context.Background(), "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":2}`)); err != nil { + t.Fatalf("write team manifest: %v", err) + } + if fake.lastSHA != "sha-existing" { + t.Fatalf("write file must include existing sha, got %q", fake.lastSHA) + } + body, err := store.ReadFile(context.Background(), "mnemon/team", ".mnemon/team.json") + if err != nil { + t.Fatalf("read team manifest: %v", err) + } + if string(body) != `{"schema_version":2}` { + t.Fatalf("read body = %s", body) + } +} + +func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.head = "head-2" + fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} + fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("list events: %v", err) + } + if len(list.Events) != 2 || list.NextCursor != "head-2" { + t.Fatalf("list = %+v, want two events at head-2", list) + } + again, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, list.NextCursor) + if err != nil { + t.Fatalf("list after head cursor: %v", err) + } + if len(again.Events) != 0 || again.NextCursor != "head-2" { + t.Fatalf("list after head cursor = %+v, want empty", again) + } +} + +func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.refs["main"] = "main-sha" + fake.missingRefs["mnemon/mnemond-run-1-a"] = true + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { + t.Fatalf("ensure branch: %v", err) + } + if fake.creates != 1 { + t.Fatalf("creates = %d, want one branch create", fake.creates) + } + if got := fake.refs["mnemon/mnemond-run-1-a"]; got != "main-sha" { + t.Fatalf("created branch sha = %q, want main-sha", got) + } + if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { + t.Fatalf("ensure branch again: %v", err) + } + if fake.creates != 1 { + t.Fatalf("idempotent ensure must not recreate branch, creates=%d", fake.creates) + } +} + +func TestGitHubPublicationStoreEnsureBranchesReadsBaseOnce(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.refs["main"] = "main-sha" + fake.missingRefs["mnemon/mnemond-run-a"] = true + fake.missingRefs["mnemon/mnemond-run-b"] = true + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + err = store.EnsureBranches(context.Background(), []string{ + "mnemon/mnemond-run-a", + "mnemon/mnemond-run-b", + "mnemon/mnemond-run-b", + }, "main") + if err != nil { + t.Fatalf("ensure branches: %v", err) + } + if fake.creates != 2 { + t.Fatalf("creates = %d, want two branch creates", fake.creates) + } + if got := fake.refReads["main"]; got != 1 { + t.Fatalf("main ref reads = %d, want one", got) + } + for _, branch := range []string{"mnemon/mnemond-run-a", "mnemon/mnemond-run-b"} { + if got := fake.refs[branch]; got != "main-sha" { + t.Fatalf("created branch %s sha = %q, want main-sha", branch, got) + } + } +} + +func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "120") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "1782416790") + writeJSON(w, http.StatusForbidden, map[string]any{"message": "API rate limit exceeded"}) + })) + t.Cleanup(server.Close) + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + err = store.EnsureBranch(context.Background(), "mnemon/mnemond-run-a", "main") + if err == nil { + t.Fatal("ensure branch should return rate-limit error") + } + msg := err.Error() + for _, want := range []string{"API rate limit exceeded", "retry_after=120", "rate_limit_remaining=0", "rate_limit_reset=1782416790"} { + if !strings.Contains(msg, want) { + t.Fatalf("error %q does not include %q", msg, want) + } + } +} + +func TestGitHubPublicationStoreLiveGated(t *testing.T) { + if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { + t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") + } + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if token == "" { + t.Skip("GITHUB_TOKEN is required for live GitHub publication store smoke test") + } + repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) + if repo == "" { + repo = "mnemon-dev/mnemon-teamwork-example" + } + branch := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH")) + if branch == "" { + branch = "mnemon/mnemond-a" + } + store, err := NewPublicationStore(PublicationStoreConfig{Repo: repo, Token: token}) + if err != nil { + t.Fatal(err) + } + path := exchange.PublicationEventRoot + "/live-smoke/progress_digest/project/000000000001-live-smoke.json" + body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) + res, err := store.PutEvent(context.Background(), branch, path, body) + if err != nil { + t.Fatalf("live put event: %v", err) + } + if !res.Created && !res.ExistsSame { + t.Fatalf("live put result = %+v, want created or exists_same", res) + } + list, err := store.ListEvents(context.Background(), branch, exchange.PublicationEventRoot, "") + if err != nil { + t.Fatalf("live list events: %v", err) + } + if list.NextCursor == "" { + t.Fatalf("live list must return a branch-head cursor: %+v", list) + } +} + +type fakeGitHubPublicationAPI struct { + server *httptest.Server + files map[string]fakeGitHubFile + refs map[string]string + missingRefs map[string]bool + refReads map[string]int + head string + puts int + creates int + lastSHA string +} + +type fakeGitHubFile struct { + body []byte + sha string +} + +func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { + t.Helper() + fake := &fakeGitHubPublicationAPI{ + files: map[string]fakeGitHubFile{}, + refs: map[string]string{}, + missingRefs: map[string]bool{}, + refReads: map[string]int{}, + head: "head-1", + } + fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) + t.Cleanup(fake.server.Close) + return fake +} + +func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request) { + if token := r.Header.Get("Authorization"); strings.Contains(token, "secret-token") && token != "Bearer secret-token" { + http.Error(w, `{"message":"bad token"}`, http.StatusUnauthorized) + return + } + prefix := "/repos/mnemon-dev/mnemon-teamwork-example/" + if !strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + tail := strings.TrimPrefix(r.URL.Path, prefix) + switch { + case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): + branch := strings.TrimPrefix(tail, "git/ref/heads/") + f.refReads[branch]++ + if f.missingRefs[branch] { + writeJSON(w, http.StatusNotFound, map[string]any{"message": "ref not found"}) + return + } + sha := f.refs[branch] + if sha == "" { + sha = f.head + } + writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": sha}}) + case r.Method == http.MethodPost && tail == "git/refs": + var req struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + branch := strings.TrimPrefix(req.Ref, "refs/heads/") + if branch == req.Ref || branch == "" || req.SHA == "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid ref"}) + return + } + delete(f.missingRefs, branch) + f.refs[branch] = req.SHA + f.creates++ + writeJSON(w, http.StatusCreated, map[string]any{"ref": req.Ref, "object": map[string]any{"sha": req.SHA}}) + case strings.HasPrefix(tail, "contents/"): + path := strings.TrimPrefix(tail, "contents/") + branch := r.URL.Query().Get("ref") + f.handleContents(w, r, branch, path) + default: + http.NotFound(w, r) + } +} + +func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http.Request, branch, path string) { + switch r.Method { + case http.MethodGet: + key := branch + ":" + path + if file, ok := f.files[key]; ok { + writeJSON(w, http.StatusOK, map[string]any{ + "type": "file", + "path": path, + "sha": file.sha, + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString(file.body), + }) + return + } + entries := f.dirEntries(branch, path) + if len(entries) > 0 { + writeJSON(w, http.StatusOK, entries) + return + } + writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"}) + case http.MethodPut: + var req struct { + Content string `json:"content"` + SHA string `json:"sha"` + Branch string `json:"branch"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + body, err := base64.StdEncoding.DecodeString(req.Content) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + if branch == "" { + branch = req.Branch + } + key := branch + ":" + path + if existing, ok := f.files[key]; ok && req.SHA != existing.sha { + writeJSON(w, http.StatusConflict, map[string]any{"message": "sha mismatch"}) + return + } + f.puts++ + f.lastSHA = req.SHA + f.files[key] = fakeGitHubFile{body: body, sha: "sha-new"} + status := http.StatusCreated + if req.SHA != "" { + status = http.StatusOK + } + writeJSON(w, status, map[string]any{"content": map[string]any{"sha": "sha-new"}}) + default: + http.NotFound(w, r) + } +} + +func (f *fakeGitHubPublicationAPI) dirEntries(branch, dir string) []map[string]any { + prefix := branch + ":" + strings.TrimSuffix(dir, "/") + "/" + seen := map[string]map[string]any{} + for key := range f.files { + if !strings.HasPrefix(key, prefix) { + continue + } + rel := strings.TrimPrefix(key, prefix) + head, _, hasRest := strings.Cut(rel, "/") + path := strings.TrimSuffix(dir, "/") + "/" + head + typ := "file" + if hasRest { + typ = "dir" + } + seen[path] = map[string]any{"type": typ, "path": path} + } + out := make([]map[string]any, 0, len(seen)) + for _, entry := range seen { + out = append(out, entry) + } + return out +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/harness/internal/mnemonhub/exchange/publication_store.go b/harness/internal/mnemonhub/exchange/publication_store.go new file mode 100644 index 00000000..d5959b82 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/publication_store.go @@ -0,0 +1,336 @@ +package exchange + +import ( + "bytes" + "context" + "fmt" + pathpkg "path" + "sort" + "strconv" + "strings" + "sync" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +const PublicationEventRoot = "mnemon-publications/v1/events" + +// PublicationStore is the storage seam under a Remote Workspace publication backend. It is +// intentionally repository-shaped but not tied to any GitHub client, so exchange semantics can be +// tested against a deterministic fake before a real API adapter exists. +type PublicationStore interface { + PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) + ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) + ReadFile(ctx context.Context, branch string, path string) ([]byte, error) + WriteFile(ctx context.Context, branch string, path string, body []byte) error +} + +type PublicationPutResult struct { + Created bool + ExistsSame bool + Conflict bool +} + +type PublicationStoredEvent struct { + Path string + Body []byte + Cursor string +} + +type PublicationListResult struct { + Events []PublicationStoredEvent + NextCursor string +} + +func PublicationEventPath(env eventmodel.EventEnvelope) (string, error) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return "", err + } + origin, err := normalizePublicationPathSegment(material.OriginReplicaID) + if err != nil { + return "", fmt.Errorf("publication origin: %w", err) + } + kind, err := normalizePublicationPathSegment(string(material.ResourceRef.Kind)) + if err != nil { + return "", fmt.Errorf("publication resource kind: %w", err) + } + resourceID, err := normalizePublicationPathSegment(string(material.ResourceRef.ID)) + if err != nil { + return "", fmt.Errorf("publication resource id: %w", err) + } + stem, err := PublicationEventFileStem(env) + if err != nil { + return "", err + } + return PublicationEventRoot + "/" + origin + "/" + kind + "/" + resourceID + "/" + stem + ".json", nil +} + +func PublicationEventFileStem(env eventmodel.EventEnvelope) (string, error) { + material, err := contract.SyncedEventMaterialFromEnvelope(env) + if err != nil { + return "", err + } + if material.LocalIngestSeq < 0 { + return "", fmt.Errorf("local_ingest_seq must be non-negative") + } + decisionID, err := normalizePublicationPathSegment(material.LocalDecisionID) + if err != nil { + return "", fmt.Errorf("publication decision id: %w", err) + } + return fmt.Sprintf("%012d-%s", material.LocalIngestSeq, decisionID), nil +} + +func NormalizePublicationBranch(branch string) (string, error) { + branch = strings.TrimSpace(branch) + if branch == "" { + return "", fmt.Errorf("publication branch is required") + } + if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + for _, part := range strings.Split(branch, "/") { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + } + if pathpkg.Clean(branch) != branch { + return "", fmt.Errorf("publication branch %q is invalid", branch) + } + if branch != "mnemon/team" && !strings.HasPrefix(branch, "mnemon/") { + return "", fmt.Errorf("publication branch %q is outside the mnemon namespace", branch) + } + return branch, nil +} + +func NormalizePublicationPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("publication path is required") + } + if strings.Contains(path, "\\") || strings.HasPrefix(path, "/") { + return "", fmt.Errorf("publication path %q is invalid", path) + } + for _, part := range strings.Split(path, "/") { + if part == ".." { + return "", fmt.Errorf("publication path %q is invalid", path) + } + } + clean := pathpkg.Clean(path) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("publication path %q is invalid", path) + } + return clean, nil +} + +type MemoryPublicationStore struct { + mu sync.Mutex + branches map[string]*memoryPublicationBranch +} + +type memoryPublicationBranch struct { + seq int64 + files map[string]memoryPublicationFile +} + +type memoryPublicationFile struct { + body []byte + seq int64 + event bool +} + +func NewMemoryPublicationStore(branches ...string) (*MemoryPublicationStore, error) { + store := &MemoryPublicationStore{branches: map[string]*memoryPublicationBranch{}} + for _, branch := range branches { + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return nil, err + } + if _, ok := store.branches[branch]; !ok { + store.branches[branch] = &memoryPublicationBranch{files: map[string]memoryPublicationFile{}} + } + } + return store, nil +} + +func (s *MemoryPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) { + if err := ctx.Err(); err != nil { + return PublicationPutResult{}, err + } + branch, path, err := s.normalizeEventRef(branch, path) + if err != nil { + return PublicationPutResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return PublicationPutResult{}, err + } + if existing, ok := b.files[path]; ok { + if bytes.Equal(existing.body, body) { + return PublicationPutResult{ExistsSame: true}, nil + } + return PublicationPutResult{Conflict: true}, nil + } + b.seq++ + b.files[path] = memoryPublicationFile{body: append([]byte(nil), body...), seq: b.seq, event: true} + return PublicationPutResult{Created: true}, nil +} + +func (s *MemoryPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) { + if err := ctx.Err(); err != nil { + return PublicationListResult{}, err + } + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return PublicationListResult{}, err + } + prefix, err = normalizePublicationEventPrefix(prefix) + if err != nil { + return PublicationListResult{}, err + } + after, err := publicationCursor(cursor) + if err != nil { + return PublicationListResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return PublicationListResult{}, err + } + events := make([]PublicationStoredEvent, 0) + for path, file := range b.files { + if !file.event || file.seq <= after || !strings.HasPrefix(path, prefix) { + continue + } + events = append(events, PublicationStoredEvent{ + Path: path, + Body: append([]byte(nil), file.body...), + Cursor: strconv.FormatInt(file.seq, 10), + }) + } + sort.Slice(events, func(i, j int) bool { + left, _ := strconv.ParseInt(events[i].Cursor, 10, 64) + right, _ := strconv.ParseInt(events[j].Cursor, 10, 64) + if left == right { + return events[i].Path < events[j].Path + } + return left < right + }) + return PublicationListResult{Events: events, NextCursor: strconv.FormatInt(b.seq, 10)}, nil +} + +func (s *MemoryPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return nil, err + } + file, ok := b.files[path] + if !ok { + return nil, fmt.Errorf("publication file %s:%s not found", branch, path) + } + return append([]byte(nil), file.body...), nil +} + +func (s *MemoryPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { + if err := ctx.Err(); err != nil { + return err + } + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return err + } + if isPublicationEventPath(path) { + return fmt.Errorf("publication event path %q must be written with PutEvent", path) + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.branch(branch) + if err != nil { + return err + } + b.seq++ + b.files[path] = memoryPublicationFile{body: append([]byte(nil), body...), seq: b.seq} + return nil +} + +func (s *MemoryPublicationStore) normalizeEventRef(branch, path string) (string, string, error) { + branch, path, err := normalizePublicationFileRef(branch, path) + if err != nil { + return "", "", err + } + if !isPublicationEventPath(path) { + return "", "", fmt.Errorf("publication event path %q must be under %s", path, PublicationEventRoot) + } + return branch, path, nil +} + +func normalizePublicationFileRef(branch, path string) (string, string, error) { + branch, err := NormalizePublicationBranch(branch) + if err != nil { + return "", "", err + } + path, err = NormalizePublicationPath(path) + if err != nil { + return "", "", err + } + return branch, path, nil +} + +func (s *MemoryPublicationStore) branch(branch string) (*memoryPublicationBranch, error) { + b, ok := s.branches[branch] + if !ok { + return nil, fmt.Errorf("publication branch %q is not configured", branch) + } + return b, nil +} + +func normalizePublicationEventPrefix(prefix string) (string, error) { + prefix, err := NormalizePublicationPath(prefix) + if err != nil { + return "", err + } + if prefix == PublicationEventRoot { + return prefix + "/", nil + } + if !isPublicationEventPath(prefix) { + return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, PublicationEventRoot) + } + return prefix, nil +} + +func isPublicationEventPath(path string) bool { + return strings.HasPrefix(path, PublicationEventRoot+"/") +} + +func publicationCursor(cursor string) (int64, error) { + cursor = strings.TrimSpace(cursor) + if cursor == "" { + return 0, nil + } + seq, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || seq < 0 { + return 0, fmt.Errorf("publication cursor %q is invalid", cursor) + } + return seq, nil +} + +func normalizePublicationPathSegment(segment string) (string, error) { + segment = strings.TrimSpace(segment) + if segment == "" || strings.Contains(segment, "/") || strings.Contains(segment, "\\") || segment == "." || segment == ".." { + return "", fmt.Errorf("path segment %q is invalid", segment) + } + return segment, nil +} diff --git a/harness/internal/mnemonhub/exchange/publication_store_test.go b/harness/internal/mnemonhub/exchange/publication_store_test.go new file mode 100644 index 00000000..012d3acf --- /dev/null +++ b/harness/internal/mnemonhub/exchange/publication_store_test.go @@ -0,0 +1,186 @@ +package exchange + +import ( + "context" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +func TestPublicationStoreEventPathDeterministic(t *testing.T) { + env := publicationTestEnvelope(t, "dec-a", "remote-entry-a", "same event") + path1, err := PublicationEventPath(env) + if err != nil { + t.Fatalf("event path: %v", err) + } + path2, err := PublicationEventPath(env) + if err != nil { + t.Fatalf("event path again: %v", err) + } + if path1 != path2 { + t.Fatalf("event path must be deterministic: %q != %q", path1, path2) + } + wantPath := PublicationEventRoot + "/replica-a/progress_digest/project/000000000007-dec-a.json" + if path1 != wantPath { + t.Fatalf("event path = %q, want explicit maintainable path %q", path1, wantPath) + } + + changed := publicationTestEnvelope(t, "dec-b", "remote-entry-b", "different event") + changedPath, err := PublicationEventPath(changed) + if err != nil { + t.Fatalf("changed event path: %v", err) + } + if changedPath == path1 { + t.Fatalf("different event material must produce a different event path: %q", changedPath) + } +} + +func TestPublicationStorePutEventIsIdempotentAndConflictAware(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + path := PublicationEventRoot + "/replica-a/progress_digest/project/000000000007-dec-a.json" + + first, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("first put: %v", err) + } + if !first.Created || first.ExistsSame || first.Conflict { + t.Fatalf("first put result = %+v, want created", first) + } + same, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"a"}`)) + if err != nil { + t.Fatalf("same put: %v", err) + } + if !same.ExistsSame || same.Created || same.Conflict { + t.Fatalf("same put result = %+v, want exists_same", same) + } + conflict, err := store.PutEvent(ctx, "mnemon/agent-a", path, []byte(`{"id":"different"}`)) + if err != nil { + t.Fatalf("conflict put: %v", err) + } + if !conflict.Conflict || conflict.Created || conflict.ExistsSame { + t.Fatalf("conflict put result = %+v, want conflict", conflict) + } + body, err := store.ReadFile(ctx, "mnemon/agent-a", path) + if err != nil { + t.Fatalf("read event file: %v", err) + } + if string(body) != `{"id":"a"}` { + t.Fatalf("conflict put must not overwrite existing body: %s", body) + } +} + +func TestPublicationStoreListEventsAfterCursor(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000002-dec-b.json", []byte("b")); err != nil { + t.Fatal(err) + } + + all, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, "") + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all.Events) != 2 || all.Events[0].Path > all.Events[1].Path || all.NextCursor != "2" { + t.Fatalf("list all = %+v, want two ordered events with cursor 2", all) + } + afterFirst, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, all.Events[0].Cursor) + if err != nil { + t.Fatalf("list after first: %v", err) + } + if len(afterFirst.Events) != 1 || afterFirst.Events[0].Path != PublicationEventRoot+"/replica-a/progress_digest/project/000000000002-dec-b.json" || afterFirst.NextCursor != "2" { + t.Fatalf("list after first = %+v, want event-b only", afterFirst) + } + empty, err := store.ListEvents(ctx, "mnemon/agent-a", PublicationEventRoot, afterFirst.NextCursor) + if err != nil { + t.Fatalf("list after next cursor: %v", err) + } + if len(empty.Events) != 0 || empty.NextCursor != "2" { + t.Fatalf("list after next cursor = %+v, want no events cursor 2", empty) + } +} + +func TestPublicationStoreRejectsUnsupportedBranchAndPath(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/agent-a") + if err != nil { + t.Fatal(err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-b", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("unsupported branch must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "refs/heads/main", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "outside the mnemon namespace") { + t.Fatalf("non-publication branch must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", "mnemon-publications/v1/../events/event-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "invalid") { + t.Fatalf("escaping event path must fail closed, got %v", err) + } + if _, err := store.PutEvent(ctx, "mnemon/agent-a", ".mnemon/reports/run.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "must be under") { + t.Fatalf("non-event PutEvent path must fail closed, got %v", err) + } + if err := store.WriteFile(ctx, "mnemon/agent-a", PublicationEventRoot+"/replica-a/progress_digest/project/000000000001-dec-a.json", []byte("a")); err == nil || !strings.Contains(err.Error(), "PutEvent") { + t.Fatalf("WriteFile must not write event paths, got %v", err) + } +} + +func TestPublicationStoreWriteReadFile(t *testing.T) { + ctx := context.Background() + store, err := NewMemoryPublicationStore("mnemon/team") + if err != nil { + t.Fatal(err) + } + if err := store.WriteFile(ctx, "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":1}`)); err != nil { + t.Fatalf("write team manifest: %v", err) + } + body, err := store.ReadFile(ctx, "mnemon/team", ".mnemon/team.json") + if err != nil { + t.Fatalf("read team manifest: %v", err) + } + if string(body) != `{"schema_version":1}` { + t.Fatalf("read body = %s", body) + } +} + +func publicationTestEnvelope(t *testing.T, decisionID, entryID, summary string) eventmodel.EventEnvelope { + t.Helper() + fields := map[string]any{ + "content": "# Progress\n- " + summary, + "items": []any{map[string]any{ + "id": entryID, + "summary": summary, + "actor": "codex@other", + "ingest_seq": float64(7), + }}, + } + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: "replica-a", + LocalDecisionID: decisionID, + LocalIngestSeq: 7, + Actor: "codex@other", + ResourceRef: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + ResourceVersion: 1, + FieldsDigest: syncTestDigestForPublication(fields), + Fields: fields, + DecidedAt: "2026-06-12T00:00:00Z", + Status: "pending", + }) + if err != nil { + t.Fatalf("synced event envelope: %v", err) + } + return env +} + +func syncTestDigestForPublication(fields map[string]any) string { + return "digest-" + fields["content"].(string) +} diff --git a/harness/internal/mnemonhub/exchange/remote_workspace.go b/harness/internal/mnemonhub/exchange/remote_workspace.go new file mode 100644 index 00000000..c4a2e9b5 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/remote_workspace.go @@ -0,0 +1,15 @@ +package exchange + +import "github.com/mnemon-dev/mnemon/harness/internal/contract" + +// RemoteWorkspace is the local mnemond side of the Remote Workspace sync ABI. +// +// The method names intentionally match the existing wire verbs so the current +// HTTP mnemon-hub client satisfies this interface without a wrapper. Future +// backends, such as a GitHub publication mesh, must present the same accepted +// synced-envelope behavior to the local sync loop. +type RemoteWorkspace interface { + SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) + SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) + SyncStatus() (contract.SyncStatusResponse, error) +} diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index 4d06cf0e..1c8d8b12 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -17,40 +17,266 @@ type RemotesDoc struct { Remotes []RemoteEntry `json:"remotes"` } +const ( + RemoteBackendHTTP = "http" + RemoteBackendGitHub = "github" +) + +const ( + RemoteDirectionBidirectional = "bidirectional" + RemoteDirectionPublish = "publish" + RemoteDirectionSubscribe = "subscribe" +) + type RemoteEntry struct { + // Backend selects the Remote Workspace implementation. Empty is the v1 + // compatibility default: the first-party HTTP mnemon-hub wire. + Backend string `json:"backend,omitempty"` + // Direction scopes how this local replica uses the remote. Empty is the v1 + // compatibility default: bidirectional push+pull. + Direction string `json:"direction,omitempty"` ID string `json:"id"` - Endpoint string `json:"endpoint"` + Endpoint string `json:"endpoint,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` CredentialRef string `json:"credential_ref"` // CAFile optionally pins the remote's TLS root (PEM bundle) — the client trusts exactly it // (sync-abi-v1 §8). Empty = the system roots. CAFile string `json:"ca_file,omitempty"` } +type RemotePlan struct { + PushTargets []RemoteEntry + PullSources []RemoteEntry +} + +func (r RemoteEntry) NormalizedBackend() string { + backend, _ := NormalizeRemoteBackend(r.Backend) + return backend +} + +func (r RemoteEntry) NormalizedDirection() string { + direction, _ := NormalizeRemoteDirection(r.Direction) + return direction +} + +func NormalizeRemoteBackend(backend string) (string, error) { + backend = strings.TrimSpace(backend) + if backend == "" { + backend = RemoteBackendHTTP + } + if err := validateRemoteBackend(backend); err != nil { + return "", err + } + return backend, nil +} + +func NormalizeRemoteDirection(direction string) (string, error) { + direction = strings.TrimSpace(direction) + if direction == "" { + direction = RemoteDirectionBidirectional + } + if err := validateRemoteDirection(direction); err != nil { + return "", err + } + return direction, nil +} + +func validateRemoteBackend(backend string) error { + switch backend { + case RemoteBackendHTTP, RemoteBackendGitHub: + return nil + default: + return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s, %s)", backend, RemoteBackendHTTP, RemoteBackendGitHub) + } +} + +func validateRemoteDirection(direction string) error { + switch direction { + case RemoteDirectionBidirectional, RemoteDirectionPublish, RemoteDirectionSubscribe: + return nil + default: + return fmt.Errorf("unsupported Remote Workspace direction %q (supported: %s, %s, %s)", direction, RemoteDirectionBidirectional, RemoteDirectionPublish, RemoteDirectionSubscribe) + } +} + // LoadRemoteEntry resolves one remote from the registry at path: id "default" follows the doc's -// `current` pointer. It validates schema version and a non-empty endpoint; credential presence is -// the caller's concern (the CLI may inject a --token override). +// `current` pointer. It validates schema version, backend, and direction; credential presence is the +// caller's concern (the CLI may inject a --token override). func LoadRemoteEntry(path, id string) (RemoteEntry, error) { + doc, err := loadRemotesDoc(path) + if err != nil { + return RemoteEntry{}, err + } + if id == "default" && strings.TrimSpace(doc.Current) != "" { + id = strings.TrimSpace(doc.Current) + } + remote, ok := findRemoteEntry(doc, id) + if !ok { + return RemoteEntry{}, fmt.Errorf("Remote Workspace %q not found in %s", id, path) + } + remote, err = normalizeRemoteEntry(remote) + if err != nil { + return RemoteEntry{}, fmt.Errorf("Remote Workspace %q: %w", id, err) + } + return remote, nil +} + +// LoadRemotePlan resolves the active Remote Workspace plan. Legacy configs with `current` and no +// explicit directions keep selecting exactly the current remote. Directional configs select all +// remotes by default so a local replica can publish to one workspace and subscribe to many sources. +func LoadRemotePlan(path, id string) (RemotePlan, error) { + doc, err := loadRemotesDoc(path) + if err != nil { + return RemotePlan{}, err + } + entries, err := selectRemotePlanEntries(doc, id) + if err != nil { + return RemotePlan{}, fmt.Errorf("%w in %s", err, path) + } + plan := RemotePlan{} + for _, entry := range entries { + entry, err = normalizeRemoteEntry(entry) + if err != nil { + return RemotePlan{}, fmt.Errorf("Remote Workspace %q: %w", entry.ID, err) + } + switch entry.Direction { + case RemoteDirectionBidirectional: + plan.PushTargets = append(plan.PushTargets, entry) + plan.PullSources = append(plan.PullSources, entry) + case RemoteDirectionPublish: + plan.PushTargets = append(plan.PushTargets, entry) + case RemoteDirectionSubscribe: + plan.PullSources = append(plan.PullSources, entry) + } + } + if len(plan.PushTargets) > 1 { + return RemotePlan{}, fmt.Errorf("multiple Remote Workspace publish targets unsupported: %s", remoteIDs(plan.PushTargets)) + } + return plan, nil +} + +func loadRemotesDoc(path string) (RemotesDoc, error) { raw, err := os.ReadFile(path) if err != nil { - return RemoteEntry{}, fmt.Errorf("read Remote Workspace config: %w", err) + return RemotesDoc{}, fmt.Errorf("read Remote Workspace config: %w", err) } var doc RemotesDoc if err := json.Unmarshal(raw, &doc); err != nil { - return RemoteEntry{}, fmt.Errorf("parse Remote Workspace config: %w", err) + return RemotesDoc{}, fmt.Errorf("parse Remote Workspace config: %w", err) } if doc.SchemaVersion != 1 { - return RemoteEntry{}, fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) + return RemotesDoc{}, fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) } - if id == "default" && strings.TrimSpace(doc.Current) != "" { - id = strings.TrimSpace(doc.Current) + return doc, nil +} + +func normalizeRemoteEntry(remote RemoteEntry) (RemoteEntry, error) { + var err error + remote.Backend, err = NormalizeRemoteBackend(remote.Backend) + if err != nil { + return RemoteEntry{}, err + } + remote.Direction, err = NormalizeRemoteDirection(remote.Direction) + if err != nil { + return RemoteEntry{}, err + } + switch remote.Backend { + case RemoteBackendHTTP: + if strings.TrimSpace(remote.Endpoint) == "" { + return RemoteEntry{}, fmt.Errorf("has no endpoint") + } + case RemoteBackendGitHub: + remote.Repo, err = NormalizeGitHubRepo(remote.Repo) + if err != nil { + return RemoteEntry{}, err + } + remote.Branch, err = NormalizePublicationBranch(remote.Branch) + if err != nil { + return RemoteEntry{}, err + } + } + return remote, nil +} + +func NormalizeGitHubRepo(repo string) (string, error) { + repo = strings.TrimSpace(repo) + if repo == "" { + return "", fmt.Errorf("github repo is required") + } + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return "", fmt.Errorf("github repo %q must be owner/name", repo) + } + for _, part := range parts { + if !validGitHubRepoSegment(part) { + return "", fmt.Errorf("github repo %q is invalid", repo) + } + } + return repo, nil +} + +func validGitHubRepoSegment(segment string) bool { + if segment == "" || segment == "." || segment == ".." { + return false + } + for _, r := range segment { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + continue + } + return false } + return true +} + +func findRemoteEntry(doc RemotesDoc, id string) (RemoteEntry, bool) { for _, remote := range doc.Remotes { if remote.ID == id { - if strings.TrimSpace(remote.Endpoint) == "" { - return RemoteEntry{}, fmt.Errorf("Remote Workspace %q has no endpoint", id) - } - return remote, nil + return remote, true } } - return RemoteEntry{}, fmt.Errorf("Remote Workspace %q not found in %s", id, path) + return RemoteEntry{}, false +} + +func selectRemotePlanEntries(doc RemotesDoc, id string) ([]RemoteEntry, error) { + id = strings.TrimSpace(id) + if id == "" { + id = "default" + } + if id != "default" { + remote, ok := findRemoteEntry(doc, id) + if !ok { + return nil, fmt.Errorf("Remote Workspace %q not found", id) + } + return []RemoteEntry{remote}, nil + } + current := strings.TrimSpace(doc.Current) + if current != "" && !hasExplicitRemoteDirection(doc) { + remote, ok := findRemoteEntry(doc, current) + if !ok { + return nil, fmt.Errorf("Remote Workspace %q not found", current) + } + return []RemoteEntry{remote}, nil + } + if len(doc.Remotes) == 0 { + return nil, fmt.Errorf("Remote Workspace config has no remotes") + } + return append([]RemoteEntry(nil), doc.Remotes...), nil +} + +func hasExplicitRemoteDirection(doc RemotesDoc) bool { + for _, remote := range doc.Remotes { + if strings.TrimSpace(remote.Direction) != "" { + return true + } + } + return false +} + +func remoteIDs(remotes []RemoteEntry) string { + ids := make([]string, 0, len(remotes)) + for _, remote := range remotes { + ids = append(ids, remote.ID) + } + return strings.Join(ids, ", ") } diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go new file mode 100644 index 00000000..d6c069d1 --- /dev/null +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -0,0 +1,246 @@ +package exchange + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadRemoteEntryDefaultsBackendToHTTP(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "hub", + "remotes": [{ + "id": "hub", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + remote, err := LoadRemoteEntry(path, "default") + if err != nil { + t.Fatalf("load legacy remote: %v", err) + } + if remote.Backend != RemoteBackendHTTP || remote.NormalizedBackend() != RemoteBackendHTTP { + t.Fatalf("legacy remote backend = %q, want %q", remote.Backend, RemoteBackendHTTP) + } + if remote.Direction != RemoteDirectionBidirectional || remote.NormalizedDirection() != RemoteDirectionBidirectional { + t.Fatalf("legacy remote direction = %q, want %q", remote.Direction, RemoteDirectionBidirectional) + } +} + +func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "mesh", + "backend": "bogus", + "credential_ref": ".mnemon/harness/sync/credentials/mesh.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemoteEntry(path, "mesh") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("unsupported backend must fail closed, got %v", err) + } +} + +func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "self", + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "repo": "mnemon-dev/mnemon-teamwork-example", + "branch": "mnemon/agent-a", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + remote, err := LoadRemoteEntry(path, "default") + if err != nil { + t.Fatalf("load github remote: %v", err) + } + if remote.Backend != RemoteBackendGitHub || remote.Direction != RemoteDirectionPublish || + remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" { + t.Fatalf("github remote not normalized: %+v", remote) + } +} + +func TestLoadRemoteEntryRejectsInvalidGitHubPublicationConfig(t *testing.T) { + cases := []struct { + name string + body string + want string + }{{ + name: "missing repo", + body: `{ + "schema_version": 1, + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "branch": "mnemon/agent-a", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`, + want: "github repo is required", + }, { + name: "bad branch", + body: `{ + "schema_version": 1, + "remotes": [{ + "id": "self", + "backend": "github", + "direction": "publish", + "repo": "mnemon-dev/mnemon-teamwork-example", + "branch": "main", + "credential_ref": ".mnemon/harness/sync/credentials/self.token" + }] + }`, + want: "outside the mnemon namespace", + }} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(tc.body+"\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadRemoteEntry(path, "self") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid github remote must fail with %q, got %v", tc.want, err) + } + }) + } +} + +func TestLoadRemotePlanLegacyCurrentIsBidirectional(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "current": "hub", + "remotes": [{ + "id": "old", + "endpoint": "http://127.0.0.1:9786", + "credential_ref": ".mnemon/harness/sync/credentials/old.token" + }, { + "id": "hub", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + plan, err := LoadRemotePlan(path, "default") + if err != nil { + t.Fatalf("load legacy plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "hub" { + t.Fatalf("legacy push targets = %+v, want hub only", plan.PushTargets) + } + if len(plan.PullSources) != 1 || plan.PullSources[0].ID != "hub" { + t.Fatalf("legacy pull sources = %+v, want hub only", plan.PullSources) + } +} + +func TestLoadRemotePlanHonorsDirections(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "pub", + "direction": "publish", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/pub.token" + }, { + "id": "sub-a", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:9788", + "credential_ref": ".mnemon/harness/sync/credentials/sub-a.token" + }, { + "id": "sub-b", + "direction": "subscribe", + "endpoint": "http://127.0.0.1:9789", + "credential_ref": ".mnemon/harness/sync/credentials/sub-b.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + plan, err := LoadRemotePlan(path, "default") + if err != nil { + t.Fatalf("load directional plan: %v", err) + } + if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "pub" { + t.Fatalf("directional push targets = %+v, want pub only", plan.PushTargets) + } + if got := remotePlanIDs(plan.PullSources); strings.Join(got, ",") != "sub-a,sub-b" { + t.Fatalf("directional pull sources = %v, want sub-a,sub-b", got) + } +} + +func TestLoadRemotePlanRejectsUnknownDirection(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "mesh", + "direction": "gossip", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/mesh.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemotePlan(path, "default") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace direction") { + t.Fatalf("unsupported direction must fail closed, got %v", err) + } +} + +func TestLoadRemotePlanRejectsMultiplePublishTargets(t *testing.T) { + path := filepath.Join(t.TempDir(), "remotes.json") + if err := os.WriteFile(path, []byte(`{ + "schema_version": 1, + "remotes": [{ + "id": "hub-a", + "direction": "publish", + "endpoint": "http://127.0.0.1:9787", + "credential_ref": ".mnemon/harness/sync/credentials/hub-a.token" + }, { + "id": "hub-b", + "direction": "bidirectional", + "endpoint": "http://127.0.0.1:9788", + "credential_ref": ".mnemon/harness/sync/credentials/hub-b.token" + }] + }`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadRemotePlan(path, "default") + if err == nil || !strings.Contains(err.Error(), "multiple Remote Workspace publish targets unsupported") { + t.Fatalf("multiple publish targets must fail closed, got %v", err) + } +} + +func remotePlanIDs(remotes []RemoteEntry) []string { + ids := make([]string, 0, len(remotes)) + for _, remote := range remotes { + ids = append(ids, remote.ID) + } + return ids +}