Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0178b04c6 |
@@ -392,6 +392,15 @@ type OrgDefaults struct {
|
||||
// InitialMemories are default memories seeded into every workspace at
|
||||
// creation time unless the workspace overrides them. Issue #1050.
|
||||
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
|
||||
// RequiredEnv / RecommendedEnv at the defaults level carry the
|
||||
// runtime's own env requirements (e.g. ANTHROPIC_API_KEY for
|
||||
// claude-code). They are injected into tmpl.RequiredEnv /
|
||||
// tmpl.RecommendedEnv during Import so collectOrgEnv picks them up
|
||||
// without needing an explicit org-level declaration. The same values
|
||||
// also flow into each workspace's config.yaml at provision time via
|
||||
// ensureDefaultConfig in workspace_provision.go.
|
||||
RequiredEnv []EnvRequirement `yaml:"required_env" json:"required_env"`
|
||||
RecommendedEnv []EnvRequirement `yaml:"recommended_env" json:"recommended_env"`
|
||||
}
|
||||
|
||||
type OrgSchedule struct {
|
||||
@@ -638,6 +647,21 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Inject runtime-required env from defaults into the template-level
|
||||
// union. Each runtime declares its own required_env (e.g.
|
||||
// ANTHROPIC_API_KEY for claude-code); without this injection,
|
||||
// collectOrgEnv only sees what's explicitly declared in
|
||||
// tmpl.RequiredEnv / OrgWorkspace.RequiredEnv and silently misses
|
||||
// the runtime's needs. The canvas's preflight modal passes a
|
||||
// correctly-populated tmpl via the `template` body path, so only
|
||||
// the `dir` (template-on-disk) path needs this injection. Issue #232.
|
||||
if len(tmpl.Defaults.RequiredEnv) > 0 {
|
||||
tmpl.RequiredEnv = append(tmpl.RequiredEnv, tmpl.Defaults.RequiredEnv...)
|
||||
}
|
||||
if len(tmpl.Defaults.RecommendedEnv) > 0 {
|
||||
tmpl.RecommendedEnv = append(tmpl.RecommendedEnv, tmpl.Defaults.RecommendedEnv...)
|
||||
}
|
||||
|
||||
// Emit started AFTER the YAML is loaded so payload.name carries the
|
||||
// resolved template name (was: empty when caller passed `dir` instead
|
||||
// of inline `template`). Pre-parse error paths above return without
|
||||
|
||||
@@ -1076,3 +1076,94 @@ func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) {
|
||||
t.Errorf("expected VALID_ONE to survive, got %v", reqNames(req))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectOrgEnv_DefaultsRequiredEnv verifies that runtime-required_env
|
||||
// declared at the defaults level is picked up by collectOrgEnv, matching the
|
||||
// injection that Import performs from Defaults.RequiredEnv into tmpl.RequiredEnv
|
||||
// before calling collectOrgEnv. Without this injection, a template with
|
||||
// defaults.runtime=claude-code and no explicit RequiredEnv would silently skip
|
||||
// ANTHROPIC_API_KEY, causing the import preflight to pass while every workspace
|
||||
// fails on first LLM call. Issue #232.
|
||||
func TestCollectOrgEnv_DefaultsRequiredEnv(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
Defaults: OrgDefaults{
|
||||
Runtime: "claude-code",
|
||||
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
|
||||
RecommendedEnv: strictReq("SERPER_API_KEY"),
|
||||
},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Worker",
|
||||
RequiredEnv: strictReq("GITHUB_TOKEN"),
|
||||
Children: []OrgWorkspace{
|
||||
{
|
||||
Name: "Leaf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
// Required is the union: ANTHROPIC_API_KEY (from defaults) + GITHUB_TOKEN (from Worker).
|
||||
wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN"}
|
||||
if !stringSlicesEqual(reqNames(req), wantReq) {
|
||||
t.Errorf("required mismatch: got %v, want %v", reqNames(req), wantReq)
|
||||
}
|
||||
wantRec := []string{"SERPER_API_KEY"}
|
||||
if !stringSlicesEqual(reqNames(rec), wantRec) {
|
||||
t.Errorf("recommended mismatch: got %v, want %v", reqNames(rec), wantRec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel tests that
|
||||
// when BOTH defaults.RequiredEnv AND an explicit org-level RequiredEnv declare
|
||||
// the same key, collectOrgEnv deduplicates and keeps only one entry.
|
||||
func TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
|
||||
Defaults: OrgDefaults{
|
||||
Runtime: "claude-code",
|
||||
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
|
||||
RecommendedEnv: strictReq("SERPER_API_KEY"),
|
||||
},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Root",
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
wantReq := []string{"ANTHROPIC_API_KEY"}
|
||||
if !stringSlicesEqual(reqNames(req), wantReq) {
|
||||
t.Errorf("required should dedupe: got %v, want %v", reqNames(req), wantReq)
|
||||
}
|
||||
if !stringSlicesEqual(reqNames(rec), []string{"SERPER_API_KEY"}) {
|
||||
t.Errorf("recommended: got %v, want [SERPER_API_KEY]", reqNames(rec))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults tests that any-of
|
||||
// groups from defaults.RequiredEnv are preserved and flow into the union.
|
||||
func TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
Defaults: OrgDefaults{
|
||||
Runtime: "claude-code",
|
||||
RequiredEnv: []EnvRequirement{
|
||||
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
},
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("expected 1 requirement, got %d: %v", len(req), reqNames(req))
|
||||
}
|
||||
if req[0].Name != "" {
|
||||
t.Errorf("expected any-of group from defaults.RequiredEnv, got strict name %q", req[0].Name)
|
||||
}
|
||||
want := []string{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"}
|
||||
got := append([]string(nil), req[0].AnyOf...)
|
||||
sort.Strings(got)
|
||||
if !stringSlicesEqual(got, want) {
|
||||
t.Errorf("any-of mismatch: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
else:
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
|
||||
|
||||
async def discover(target_id: str) -> dict | None:
|
||||
|
||||
@@ -29,7 +29,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
else:
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
|
||||
# Cache workspace ID → name mappings (populated by list_peers calls)
|
||||
_peer_names: dict[str, str] = {}
|
||||
|
||||
@@ -54,16 +54,6 @@ import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _platform_url() -> str:
|
||||
"""Return the platform URL, defaulting to host.docker.internal when running
|
||||
inside a Docker container (where localhost refers to the container, not the
|
||||
host). External callers can always override via the PLATFORM_URL env var.
|
||||
"""
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -89,12 +79,12 @@ async def _fetch_latest_checkpoint(workspace_id: str) -> Optional[dict]:
|
||||
workspace_id: The workspace to query.
|
||||
|
||||
Reads:
|
||||
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
|
||||
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
|
||||
"""
|
||||
try:
|
||||
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
|
||||
|
||||
platform_url = _platform_url()
|
||||
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints/latest"
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url, headers=_auth_headers())
|
||||
@@ -135,12 +125,12 @@ async def _save_checkpoint(
|
||||
payload: Optional JSON-serialisable dict stored as JSONB.
|
||||
|
||||
Reads:
|
||||
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
|
||||
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
|
||||
"""
|
||||
try:
|
||||
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
|
||||
|
||||
platform_url = _platform_url()
|
||||
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints"
|
||||
body: dict = {
|
||||
"workflow_id": workflow_id,
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
else:
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
|
||||
if not _WORKSPACE_ID_raw:
|
||||
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
|
||||
|
||||
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
else:
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
|
||||
if not _WORKSPACE_ID_raw:
|
||||
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ async def main(): # pragma: no cover
|
||||
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
|
||||
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
else:
|
||||
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
|
||||
awareness_config = get_awareness_config()
|
||||
|
||||
# 0. Initialise OpenTelemetry (no-op if packages not installed)
|
||||
|
||||
Reference in New Issue
Block a user