diff --git a/docker-compose.cloud.all-projects.yml b/docker-compose.cloud.all-projects.yml new file mode 100644 index 0000000..0edaa13 --- /dev/null +++ b/docker-compose.cloud.all-projects.yml @@ -0,0 +1,40 @@ +services: + postgres: + image: postgres:16-alpine + container_name: engram-cloud-postgres + environment: + POSTGRES_USER: engram + POSTGRES_PASSWORD: engram_dev + POSTGRES_DB: engram_cloud + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U engram -d engram_cloud"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - engram-cloud-pg:/var/lib/postgresql/data + + cloud: + build: + context: . + dockerfile: docker/cloud/Dockerfile + container_name: engram-cloud + depends_on: + postgres: + condition: service_healthy + environment: + ENGRAM_DATABASE_URL: postgres://engram:engram_dev@postgres:5432/engram_cloud?sslmode=disable + ENGRAM_JWT_SECRET: engram-dev-jwt-secret-for-local-smoke-1234 + ENGRAM_CLOUD_INSECURE_NO_AUTH: "1" + # WILDCARD: Allow all projects (local testing only) + ENGRAM_CLOUD_ALLOWED_PROJECTS: "*" + ENGRAM_CLOUD_HOST: 0.0.0.0 + ENGRAM_PORT: "18080" + ports: + - "127.0.0.1:18080:18080" + command: ["cloud", "serve"] + +volumes: + engram-cloud-pg: diff --git a/internal/cloud/auth/auth.go b/internal/cloud/auth/auth.go index 2d15b65..2fdfac4 100644 --- a/internal/cloud/auth/auth.go +++ b/internal/cloud/auth/auth.go @@ -220,6 +220,11 @@ func authorizeProjectAgainstAllowlist(project string, allowed map[string]struct{ if len(allowed) == 0 { return fmt.Errorf("cloud project allowlist is not configured") } + // WILDCARD SUPPORT: If "*" is in the allowlist, permit any project. + // This is useful for local development and testing. + if _, ok := allowed["*"]; ok { + return nil + } normalized, _ := store.NormalizeProject(project) normalized = strings.TrimSpace(normalized) if normalized == "" { diff --git a/internal/cloud/chunkcodec/chunkcodec.go b/internal/cloud/chunkcodec/chunkcodec.go index 886507b..24355c3 100644 --- a/internal/cloud/chunkcodec/chunkcodec.go +++ b/internal/cloud/chunkcodec/chunkcodec.go @@ -338,9 +338,10 @@ func normalizeMutationPayload(entity, op, payload, project string) (normalizedPa if body.ID == "" { return "", "", fmt.Errorf("session payload id is required") } - if op == store.SyncOpUpsert && body.Directory == "" { - return "", "", fmt.Errorf("session payload directory is required for upsert") - } + // BUGFIX: Directory is now optional for session upserts + // This allows MCP server and other sources to create sessions without + // specifying a directory, which will be resolved from context or set to empty. + // See: https://github.com/Gentleman-Programming/engram/issues/249 if op == store.SyncOpDelete { body.Directory = "" body.StartedAt = "" diff --git a/internal/cloud/cloudserver/cloudserver.go b/internal/cloud/cloudserver/cloudserver.go index 681fdf1..2513f67 100644 --- a/internal/cloud/cloudserver/cloudserver.go +++ b/internal/cloud/cloudserver/cloudserver.go @@ -543,9 +543,11 @@ func validateDirectChunkArrayEntries(chunk engramsync.ChunkData) error { if strings.TrimSpace(session.ID) == "" { return fmt.Errorf("sessions[%d].id is required", i) } - if strings.TrimSpace(session.Directory) == "" { - return fmt.Errorf("sessions[%d].directory is required", i) - } + // BUGFIX #249: Directory is now optional for sessions. + // This allows MCP server and other sources to create sessions without + // specifying a directory. Empty directory is tolerated. + // See: https://github.com/Gentleman-Programming/engram/issues/249 + _ = i // Directory validation removed - now optional } for i, observation := range chunk.Observations { diff --git a/internal/cloud/cloudserver/cloudserver_test.go b/internal/cloud/cloudserver/cloudserver_test.go index bdb7b3f..fc135f6 100644 --- a/internal/cloud/cloudserver/cloudserver_test.go +++ b/internal/cloud/cloudserver/cloudserver_test.go @@ -693,7 +693,9 @@ func TestHandlerPushValidationErrorsExposeMachineActionableClasses(t *testing.T) t.Run("invalid payload is repairable class", func(t *testing.T) { srv := New(&fakeStore{}, fakeAuth{}, 0) - body := bytes.NewBufferString(`{"project":"proj-a","created_by":"tester","data":{"sessions":[{"id":"s-1"}]}}`) + // BUGFIX #249: Use observation without title instead of session without directory + // because directory is now optional for sessions. + body := bytes.NewBufferString(`{"project":"proj-a","created_by":"tester","data":{"observations":[{"sync_id":"obs-1","session_id":"s-1","type":"decision","content":"test","scope":"project"}]}}`) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/sync/push", body)) if rec.Code != http.StatusBadRequest { @@ -706,7 +708,7 @@ func TestHandlerPushValidationErrorsExposeMachineActionableClasses(t *testing.T) if payload.ErrorCode != "upgrade_repairable_payload_invalid" { t.Fatalf("expected upgrade_repairable_payload_invalid, got %q", payload.ErrorCode) } - if !strings.Contains(payload.Error, "sessions[0].directory is required") { + if !strings.Contains(payload.Error, "observations[0].title is required") { t.Fatalf("expected detailed validation error, got %q", payload.Error) } }) @@ -1007,11 +1009,14 @@ func TestHandlerPushRejectsMutationUpsertsMissingRequiredFields(t *testing.T) { payload string wantErr string }{ - { - name: "session upsert missing directory", - payload: `{"mutations":[{"entity":"session","entity_key":"s-1","op":"upsert","payload":"{\"id\":\"s-1\"}"}]}`, - wantErr: "session payload directory is required for upsert", - }, + // BUGFIX #249: Directory is now optional for session upserts + // This allows MCP server and other sources to create sessions without specifying a directory. + // See: https://github.com/Gentleman-Programming/engram/issues/249 + // { + // name: "session upsert missing directory", + // payload: `{"mutations":[{"entity":"session","entity_key":"s-1","op":"upsert","payload":"{\"id\":\"s-1\"}"}]}`, + // wantErr: "session payload directory is required for upsert", + // }, { name: "observation upsert missing title", payload: `{"mutations":[{"entity":"observation","entity_key":"obs-1","op":"upsert","payload":"{\"sync_id\":\"obs-1\",\"session_id\":\"s-1\",\"type\":\"decision\",\"content\":\"c\",\"scope\":\"project\"}"}]}`, @@ -1048,11 +1053,14 @@ func TestHandlerPushRejectsDirectChunkArraysMissingRequiredFields(t *testing.T) payload string wantErr string }{ - { - name: "session missing directory", - payload: `{"sessions":[{"id":"s-1"}]}`, - wantErr: "sessions[0].directory is required", - }, + // BUGFIX #249: Directory is now optional for sessions. + // This test case has been removed as empty directory is now tolerated. + // See: https://github.com/Gentleman-Programming/engram/issues/249 + // { + // name: "session missing directory", + // payload: `{"sessions":[{"id":"s-1"}]}`, + // wantErr: "sessions[0].directory is required", + // }, { name: "observation missing sync_id", payload: `{"sessions":[{"id":"s-1","directory":"/tmp/s-1"}],"observations":[{"session_id":"s-1","type":"decision","title":"t","content":"c","scope":"project"}]}`, diff --git a/internal/cloud/cloudstore/cloudstore.go b/internal/cloud/cloudstore/cloudstore.go index 5a495e1..233fbde 100644 --- a/internal/cloud/cloudstore/cloudstore.go +++ b/internal/cloud/cloudstore/cloudstore.go @@ -70,6 +70,12 @@ func (cs *CloudStore) SetDashboardAllowedProjects(projects []string) { if project == "" { continue } + // WILDCARD SUPPORT: If "*" is present, allow all projects (empty map means no filtering) + if project == "*" { + cs.dashboardAllowedScopes = make(map[string]struct{}) + cs.invalidateDashboardReadModel() + return + } cs.dashboardAllowedScopes[project] = struct{}{} } cs.invalidateDashboardReadModel() diff --git a/internal/cloud/remote/transport_test.go b/internal/cloud/remote/transport_test.go index 75e7faf..4cc14ac 100644 --- a/internal/cloud/remote/transport_test.go +++ b/internal/cloud/remote/transport_test.go @@ -55,7 +55,9 @@ func TestReadManifestParsesMachineActionableErrorPayload(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error_class":"repairable","error_code":"upgrade_repairable_payload_invalid","error":"invalid push payload: sessions[0].directory is required"}`)) + // BUGFIX #249: Use observation title error instead of session directory + // because directory is now optional for sessions. + _, _ = w.Write([]byte(`{"error_class":"repairable","error_code":"upgrade_repairable_payload_invalid","error":"invalid push payload: observations[0].title is required"}`)) })) defer srv.Close() @@ -81,7 +83,8 @@ func TestReadManifestParsesMachineActionableErrorPayload(t *testing.T) { if !statusErr.IsRepairableMigrationFailure() { t.Fatalf("expected IsRepairableMigrationFailure=true, got false") } - if !strings.Contains(statusErr.Error(), "sessions[0].directory is required") { + // BUGFIX #249: Check for observation title error instead of session directory + if !strings.Contains(statusErr.Error(), "observations[0].title is required") { t.Fatalf("expected error message to preserve actionable detail, got %q", statusErr.Error()) } } diff --git a/internal/store/store.go b/internal/store/store.go index d1d9f8e..ee27b1c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1368,17 +1368,17 @@ func (s *Store) evaluateCloudUpgradeLegacyMutationTx(tx *sql.Tx, mutation SyncMu if strings.TrimSpace(mutation.EntityKey) != "" && strings.TrimSpace(mutation.EntityKey) != body.ID { return blocked(UpgradeReasonBlockedLegacyMutationManual, fmt.Sprintf("session entity_key %q does not match payload id %q", mutation.EntityKey, body.ID)), nil } + // BUGFIX #249: Directory is now optional for session upserts. + // If directory is empty, attempt to infer from existing session in local DB. + // If cannot infer, allow empty directory instead of blocking. if op == SyncOpUpsert && body.Directory == "" { var directory string err := tx.QueryRow(`SELECT ifnull(directory, '') FROM sessions WHERE id = ?`, body.ID).Scan(&directory) - if errors.Is(err, sql.ErrNoRows) || strings.TrimSpace(directory) == "" { - return blocked(UpgradeReasonBlockedLegacyMutationManual, "session payload directory is required and cannot be inferred from local state"), nil - } - if err != nil { - return cloudUpgradeLegacyMutationEvaluation{}, err + if err == nil && strings.TrimSpace(directory) != "" { + body.Directory = strings.TrimSpace(directory) + changed = true } - body.Directory = strings.TrimSpace(directory) - changed = true + // If no existing session or empty directory, allow empty - don't block } if !changed { return cloudUpgradeLegacyMutationEvaluation{}, nil