feat(workspaces): RFC #2948 Phase 1 — decouple template from runtime #2980
@@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "claude-code", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "claude-code", "", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -81,7 +81,7 @@ func TestWorkspaceCreate_DefaultsParentToPlatformRoot(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Team Member", nil, 3, "claude-code", rootID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Team Member", nil, 3, "claude-code", "", rootID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -118,7 +118,7 @@ func TestWorkspaceCreate_NoPlatformRoot_KeepsNullParent(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Bootstrap Root", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Bootstrap Root", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -150,7 +150,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
|
||||
mock.ExpectBegin()
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -374,7 +374,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1111,8 +1111,8 @@ func TestRestart_ParentPaused(t *testing.T) {
|
||||
// Workspace lookup succeeds
|
||||
mock.ExpectQuery("SELECT status, name, tier").
|
||||
WithArgs("dddddddd-0001-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Child Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "Child Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: get parent_id
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
||||
@@ -1154,8 +1154,8 @@ func TestRestart_ProvisionerNil(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier").
|
||||
WithArgs("ws-noprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
||||
@@ -1389,8 +1389,8 @@ func TestResume_ProvisionerNil(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT name, tier").
|
||||
WithArgs("ws-resume-noprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime"}).
|
||||
AddRow("Paused Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime", "template"}).
|
||||
AddRow("Paused Agent", 1, "claude-code", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestExtended_WorkspaceRestart_NoProvisioner(t *testing.T) {
|
||||
// Expect SELECT for workspace existence check (includes runtime column)
|
||||
mock.ExpectQuery("SELECT status, name, tier").
|
||||
WithArgs("ws-restart").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).AddRow("offline", "Restarting Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).AddRow("offline", "Restarting Agent", 1, "claude-code", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -385,7 +385,7 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect transaction commit (no secrets in this payload)
|
||||
@@ -464,7 +464,7 @@ func TestWorkspaceCreate_ReturnsAuthToken_201(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Token Holder", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Token Holder", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
|
||||
@@ -298,6 +298,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
// whether to do prep at all.
|
||||
payload := models.CreateWorkspacePayload{
|
||||
Name: ws.Name, Tier: tier, Runtime: runtime, Model: model,
|
||||
Template: ws.Template,
|
||||
WorkspaceDir: ws.WorkspaceDir,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@ type restartTemplateInput struct {
|
||||
// 3. `RebuildConfig=true` → org-templates recovery fallback (#239).
|
||||
// 4. `ApplyTemplate=true` + non-empty dbRuntime → runtime-default template
|
||||
// (e.g. `hermes-default/`) for runtime-change workflows.
|
||||
// 5. Fall through → empty path + "existing-volume" label. Provisioner
|
||||
// 5. Persisted `dbTemplate` (set by PATCH /workspaces/:id/template) when no
|
||||
// explicit body template was supplied. This makes `PATCH template=...`
|
||||
// followed by a plain `POST /restart` actually apply the newly stored
|
||||
// template instead of silently reusing the existing config volume.
|
||||
// 6. Fall through → empty path + "existing-volume" label. Provisioner
|
||||
// reuses the workspace's existing config volume from the previous run.
|
||||
//
|
||||
// Returns (templatePath, configLabel). An empty templatePath is the signal
|
||||
@@ -48,7 +52,7 @@ type restartTemplateInput struct {
|
||||
//
|
||||
// Pure function: no writes, no DB access, no network. Safe to unit-test
|
||||
// with just a temp directory.
|
||||
func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTemplateInput) (templatePath, configLabel string) {
|
||||
func resolveRestartTemplate(configsDir, wsName, dbRuntime, dbTemplate string, body restartTemplateInput) (templatePath, configLabel string) {
|
||||
template := body.Template
|
||||
|
||||
// Tier 2: name-based auto-match, gated on ApplyTemplate.
|
||||
@@ -102,7 +106,22 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 5: reuse existing volume. This is the default, and the path
|
||||
// Tier 5: persisted template from PATCH /workspaces/:id/template.
|
||||
// When the caller supplied no explicit body template, fall back to the
|
||||
// template stored in the DB. This makes `PATCH template=seo-agent`
|
||||
// followed by `POST /restart` with no body apply the newly installed
|
||||
// template instead of reusing the stale config volume.
|
||||
if body.Template == "" && dbTemplate != "" {
|
||||
if candidatePath, resolveErr := resolveInsideRoot(configsDir, dbTemplate); resolveErr != nil {
|
||||
log.Printf("Restart: invalid persisted template %q: %v — proceeding without it", dbTemplate, resolveErr)
|
||||
} else if _, err := os.Stat(candidatePath); err == nil {
|
||||
return candidatePath, dbTemplate
|
||||
} else {
|
||||
log.Printf("Restart: persisted template %q dir not found — proceeding without it", dbTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 6: reuse existing volume. This is the default, and the path
|
||||
// the Canvas Save+Restart flow MUST hit to preserve user edits.
|
||||
return "", "existing-volume"
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestResolveRestartTemplate_DefaultRestart_PreservesVolume(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Hermes Agent", "hermes", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Hermes Agent", "hermes", "", restartTemplateInput{
|
||||
// ApplyTemplate intentionally omitted — this is the default restart.
|
||||
})
|
||||
if path != "" {
|
||||
@@ -71,7 +71,7 @@ func TestResolveRestartTemplate_DefaultRestart_PreservesVolume(t *testing.T) {
|
||||
func TestResolveRestartTemplate_ExplicitTemplate_AlwaysHonoured(t *testing.T) {
|
||||
root := newTemplateDir(t, "claude-code")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", "", restartTemplateInput{
|
||||
Template: "claude-code",
|
||||
})
|
||||
if path == "" || label != "claude-code" {
|
||||
@@ -85,7 +85,7 @@ func TestResolveRestartTemplate_ExplicitTemplate_AlwaysHonoured(t *testing.T) {
|
||||
func TestResolveRestartTemplate_ApplyTemplate_NameMatch(t *testing.T) {
|
||||
root := newTemplateDir(t, "hermes")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Hermes", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Hermes", "", "", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
if path == "" || label != "hermes" {
|
||||
@@ -100,7 +100,7 @@ func TestResolveRestartTemplate_ApplyTemplate_NameMatch(t *testing.T) {
|
||||
func TestResolveRestartTemplate_ApplyTemplate_RuntimeDefault(t *testing.T) {
|
||||
root := newTemplateDir(t, "hermes-default")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "hermes", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "hermes", "", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
if path == "" || label != "hermes-default" {
|
||||
@@ -179,7 +179,7 @@ func TestRestartRuntimeFromConfig_DefaultRestartPreservesContainerRuntime(t *tes
|
||||
func TestResolveRestartTemplate_ApplyTemplate_NoMatch_NoRuntime(t *testing.T) {
|
||||
root := newTemplateDir(t) // empty templates dir
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Orphan", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Orphan", "", "", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
if path != "" {
|
||||
@@ -197,7 +197,7 @@ func TestResolveRestartTemplate_ApplyTemplate_NoMatch_NoRuntime(t *testing.T) {
|
||||
func TestResolveRestartTemplate_InvalidExplicitTemplate_ProceedsWithout(t *testing.T) {
|
||||
root := newTemplateDir(t, "claude-code")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", "", restartTemplateInput{
|
||||
Template: "../../etc/passwd",
|
||||
})
|
||||
if path != "" {
|
||||
@@ -214,7 +214,7 @@ func TestResolveRestartTemplate_InvalidExplicitTemplate_ProceedsWithout(t *testi
|
||||
func TestResolveRestartTemplate_NonExistentExplicitTemplate(t *testing.T) {
|
||||
root := newTemplateDir(t, "claude-code")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Agent", "", "", restartTemplateInput{
|
||||
Template: "deleted-template",
|
||||
})
|
||||
if path != "" {
|
||||
@@ -232,7 +232,7 @@ func TestResolveRestartTemplate_NonExistentExplicitTemplate(t *testing.T) {
|
||||
func TestResolveRestartTemplate_Priority_ExplicitBeatsApplyTemplate(t *testing.T) {
|
||||
root := newTemplateDir(t, "hermes", "claude-code")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Hermes", "", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Hermes", "", "", restartTemplateInput{
|
||||
Template: "claude-code",
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
@@ -279,7 +279,7 @@ func TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough(t *testing.T
|
||||
{"deep traversal", "a/b/c/../../../d"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", tc.dbRuntime, restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", tc.dbRuntime, "", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
// Must NOT return a path that escapes root
|
||||
@@ -300,7 +300,7 @@ func TestResolveRestartTemplate_CWE22_TraversalRuntime_FallsThrough(t *testing.T
|
||||
func TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntime(t *testing.T) {
|
||||
root := newTemplateDir(t, "claude-code-default")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "../../../etc", restartTemplateInput{
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "../../../etc", "", restartTemplateInput{
|
||||
ApplyTemplate: true,
|
||||
})
|
||||
// Must resolve to claude-code-default (the safe default after sanitizeRuntime),
|
||||
@@ -313,3 +313,56 @@ func TestResolveRestartTemplate_CWE22_TraversalRuntime_CannotOverrideKnownRuntim
|
||||
t.Errorf("label must be claude-code-default; got %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRestartTemplate_PersistedTemplate_FallsBack verifies that a
|
||||
// workspace with a non-empty DB template uses that template on a plain
|
||||
// restart when no body template or apply/rebuild flags are supplied.
|
||||
// Regression test for core#2980 review feedback.
|
||||
func TestResolveRestartTemplate_PersistedTemplate_FallsBack(t *testing.T) {
|
||||
root := newTemplateDir(t, "seo-agent")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "claude-code", "seo-agent", restartTemplateInput{
|
||||
// no body.Template, no ApplyTemplate, no RebuildConfig
|
||||
})
|
||||
expected := filepath.Join(root, "seo-agent")
|
||||
if path != expected {
|
||||
t.Errorf("persisted template fallback: expected path %q, got %q", expected, path)
|
||||
}
|
||||
if label != "seo-agent" {
|
||||
t.Errorf("persisted template fallback: expected label %q, got %q", "seo-agent", label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRestartTemplate_PersistedTemplate_EmptyPreservesVolume verifies
|
||||
// that workspaces with an empty DB template still reuse the existing config
|
||||
// volume on a plain restart.
|
||||
func TestResolveRestartTemplate_PersistedTemplate_EmptyPreservesVolume(t *testing.T) {
|
||||
root := newTemplateDir(t, "seo-agent")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "claude-code", "", restartTemplateInput{})
|
||||
if path != "" {
|
||||
t.Errorf("empty persisted template must preserve volume; got path=%q", path)
|
||||
}
|
||||
if label != "existing-volume" {
|
||||
t.Errorf("empty persisted template must fall back to existing-volume; got label=%q", label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRestartTemplate_ExplicitBodyTemplateOverridesPersistedTemplate
|
||||
// verifies that a template named in the request body still wins over the
|
||||
// stored template.
|
||||
func TestResolveRestartTemplate_ExplicitBodyTemplateOverridesPersistedTemplate(t *testing.T) {
|
||||
root := newTemplateDir(t, "seo-agent", "hermes")
|
||||
|
||||
path, label := resolveRestartTemplate(root, "Some Workspace", "claude-code", "seo-agent", restartTemplateInput{
|
||||
Template: "hermes",
|
||||
})
|
||||
expected := filepath.Join(root, "hermes")
|
||||
if path != expected {
|
||||
t.Errorf("explicit body template must win; expected path %q, got %q", expected, path)
|
||||
}
|
||||
if label != "hermes" {
|
||||
t.Errorf("explicit body template must win; expected label %q, got %q", "hermes", label)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -348,3 +348,32 @@ func templateIdentityForRuntime(runtime string) (string, bool) {
|
||||
}
|
||||
return rr.Repo + "@" + rr.Ref, true
|
||||
}
|
||||
|
||||
// isKnownTemplate reports whether name is a registered workspace template in
|
||||
// manifest.json. The empty string is intentionally NOT known — it is the
|
||||
// "no installed template" sentinel and falls back to runtime resolution.
|
||||
func isKnownTemplate(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := templateRepoByName[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resolveTemplateIdentity returns the Gitea template identity (repo@ref) for a
|
||||
// workspace. If a template is explicitly installed, it wins; otherwise the
|
||||
// runtime's default template is used. This is the SSOT for the RFC #2843 asset
|
||||
// fetcher and for the control-plane provision wire.
|
||||
//
|
||||
// Fail-closed: an explicitly set but unknown template returns ("", false) so
|
||||
// callers can surface a 422 instead of silently degrading to the runtime
|
||||
// fallback (matches the create-boundary posture for unknown runtimes).
|
||||
func resolveTemplateIdentity(template, runtime string) (string, bool) {
|
||||
if template != "" {
|
||||
if rr, ok := templateRepoByName[template]; ok {
|
||||
return rr.Repo + "@" + rr.Ref, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return templateIdentityForRuntime(runtime)
|
||||
}
|
||||
|
||||
@@ -223,23 +223,94 @@ func TestTemplateIdentityForRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateIdentityForRuntimeOrEmpty pins the
|
||||
// single-expression wrapper used at the call site in
|
||||
// buildProvisionerConfig.
|
||||
func TestTemplateIdentityForRuntimeOrEmpty(t *testing.T) {
|
||||
// TestTemplateIdentityOrEmpty pins the single-expression wrapper used at the
|
||||
// call site in buildProvisionerConfig.
|
||||
func TestTemplateIdentityOrEmpty(t *testing.T) {
|
||||
if manifestPath() == "" {
|
||||
t.Skip("manifest.json not discoverable from this test cwd")
|
||||
}
|
||||
initTemplateRepoByName()
|
||||
if got := templateIdentityForRuntimeOrEmpty("claude-code"); got == "" {
|
||||
if got := templateIdentityOrEmpty(resolveTemplateIdentity("", "claude-code")); got == "" {
|
||||
t.Error("claude-code should return a non-empty identity")
|
||||
}
|
||||
if got := templateIdentityForRuntimeOrEmpty("external"); got != "" {
|
||||
if got := templateIdentityOrEmpty(resolveTemplateIdentity("", "external")); got != "" {
|
||||
t.Errorf("external should return empty, got %q", got)
|
||||
}
|
||||
if got := templateIdentityForRuntimeOrEmpty("unknown-xyz"); got != "" {
|
||||
if got := templateIdentityOrEmpty(resolveTemplateIdentity("", "unknown-xyz")); got != "" {
|
||||
t.Errorf("unknown-xyz should return empty, got %q", got)
|
||||
}
|
||||
// Phase 1: explicit template wins over runtime fallback.
|
||||
if got := templateIdentityOrEmpty(resolveTemplateIdentity("claude-code", "external")); got == "" {
|
||||
t.Error("explicit claude-code template should return a non-empty identity even when runtime is external")
|
||||
}
|
||||
if got := templateIdentityOrEmpty(resolveTemplateIdentity("unknown-template-xyz", "claude-code")); got != "" {
|
||||
t.Errorf("unknown explicit template should fail-closed to empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTemplateIdentity pins the Phase 1 template-first resolver.
|
||||
func TestResolveTemplateIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "manifest.json")
|
||||
manifest := `{
|
||||
"workspace_templates": [
|
||||
{"name": "claude-code-default", "repo": "molecule-ai/t-cc", "ref": "main"},
|
||||
{"name": "seo-agent", "repo": "molecule-ai/t-seo", "ref": "v1"},
|
||||
{"name": "hermes", "repo": "molecule-ai/t-hermes", "ref": "v2"}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(manifest), 0600); err != nil {
|
||||
t.Fatalf("write temp manifest: %v", err)
|
||||
}
|
||||
t.Setenv("WORKSPACE_MANIFEST_PATH", p)
|
||||
initTemplateRepoByName()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
template string
|
||||
runtime string
|
||||
wantOk bool
|
||||
wantRepo string
|
||||
}{
|
||||
{"template wins", "seo-agent", "claude-code", true, "molecule-ai/t-seo@v1"},
|
||||
{"template empty falls back to runtime", "", "claude-code", true, "molecule-ai/t-cc@main"},
|
||||
{"template empty external returns empty", "", "external", false, ""},
|
||||
{"unknown explicit template fail-closed", "no-such-template", "claude-code", false, ""},
|
||||
{"unknown runtime empty", "", "no-such-runtime", false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
id, ok := resolveTemplateIdentity(c.template, c.runtime)
|
||||
if ok != c.wantOk {
|
||||
t.Fatalf("template=%q runtime=%q: want ok=%v, got ok=%v", c.template, c.runtime, c.wantOk, ok)
|
||||
}
|
||||
if id != c.wantRepo {
|
||||
t.Errorf("template=%q runtime=%q: want id=%q, got %q", c.template, c.runtime, c.wantRepo, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsKnownTemplate pins the manifest-entry gate used by PATCH /template.
|
||||
func TestIsKnownTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "manifest.json")
|
||||
manifest := `{"workspace_templates": [{"name": "seo-agent", "repo": "r", "ref": "main"}]}`
|
||||
if err := os.WriteFile(p, []byte(manifest), 0600); err != nil {
|
||||
t.Fatalf("write temp manifest: %v", err)
|
||||
}
|
||||
t.Setenv("WORKSPACE_MANIFEST_PATH", p)
|
||||
initTemplateRepoByName()
|
||||
|
||||
if isKnownTemplate("") {
|
||||
t.Error("empty template should not be known")
|
||||
}
|
||||
if !isKnownTemplate("seo-agent") {
|
||||
t.Error("seo-agent should be known")
|
||||
}
|
||||
if isKnownTemplate("ghost") {
|
||||
t.Error("ghost template should not be known")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateIdentityForTemplateOrRuntime is the #32 regression gate: a
|
||||
@@ -309,7 +380,7 @@ func TestInitTemplateRepoByName_PopulatesMap_FromTempManifest(t *testing.T) {
|
||||
|
||||
// Assert the map is populated for the shipped runtimes.
|
||||
cases := []struct {
|
||||
runtime string
|
||||
runtime string
|
||||
wantRepo string
|
||||
wantRef string
|
||||
}{
|
||||
|
||||
@@ -681,10 +681,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// returns the actually-persisted name (which we MUST thread back into
|
||||
// payload + broadcast so the canvas displays what the DB has).
|
||||
const insertWorkspaceSQL = `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, template, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.Template, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx,
|
||||
tx,
|
||||
|
||||
@@ -152,6 +152,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"claude-code", // runtime
|
||||
"", // template
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
|
||||
@@ -752,6 +752,62 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string, erase b
|
||||
return descendantIDs, stopErrs, nil
|
||||
}
|
||||
|
||||
// PatchTemplate handles PATCH /workspaces/:id/template.
|
||||
// It sets the installed template for an existing workspace without changing
|
||||
// its engine runtime. A restart/re-provision is required for the template
|
||||
// assets to be fetched and applied.
|
||||
//
|
||||
// Auth: admin/CP-gated at the router (mirrors PATCH /workspaces/:id/budget).
|
||||
func (h *WorkspaceHandler) PatchTemplate(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := validateWorkspaceID(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Template string `json:"template" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "template is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isKnownTemplate(body.Template) {
|
||||
log.Printf("PatchTemplate: %q is not a known workspace template", body.Template)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "unsupported workspace template",
|
||||
"template": body.Template,
|
||||
"code": "TEMPLATE_UNSUPPORTED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`, id,
|
||||
).Scan(&exists); err != nil {
|
||||
log.Printf("PatchTemplate: existence check failed for %s: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.DB.ExecContext(c.Request.Context(),
|
||||
`UPDATE workspaces SET template = $2, updated_at = now() WHERE id = $1`, id, body.Template,
|
||||
); err != nil {
|
||||
log.Printf("PatchTemplate: update failed for %s: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PatchTemplate: workspace %s template updated to %q", id, body.Template)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "needs_restart": true})
|
||||
}
|
||||
|
||||
// validateWorkspaceID returns an error when id is not a valid UUID.
|
||||
// #687: prevents 500s from Postgres when a garbage string (e.g. ../../etc/passwd)
|
||||
// is passed as the :id path parameter.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -751,6 +752,113 @@ func TestUpdate_Runtime_ModelUnresolved_SkipsCheckAndProceeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchTemplate pins the admin/CP-gated PATCH /workspaces/:id/template endpoint.
|
||||
func TestPatchTemplate(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// Wire a temp manifest so seo-agent is a known template.
|
||||
dir := t.TempDir()
|
||||
manifestPath := dir + "/manifest.json"
|
||||
manifest := `{"workspace_templates": [{"name": "seo-agent", "repo": "molecule-ai/t-seo", "ref": "main"}]}`
|
||||
if err := os.WriteFile(manifestPath, []byte(manifest), 0600); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
t.Setenv("WORKSPACE_MANIFEST_PATH", manifestPath)
|
||||
initTemplateRepoByName()
|
||||
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.PATCH("/workspaces/:id/template", h.PatchTemplate)
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET template = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsID, "seo-agent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{"template": "seo-agent"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID+"/template", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse response: %v", err)
|
||||
}
|
||||
if resp["status"] != "updated" {
|
||||
t.Errorf("expected status=updated, got %v", resp["status"])
|
||||
}
|
||||
if resp["needs_restart"] != true {
|
||||
t.Errorf("expected needs_restart=true, got %v", resp["needs_restart"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchTemplate_UnknownTemplate_Fails422(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
dir := t.TempDir()
|
||||
manifestPath := dir + "/manifest.json"
|
||||
manifest := `{"workspace_templates": [{"name": "seo-agent", "repo": "r", "ref": "main"}]}`
|
||||
if err := os.WriteFile(manifestPath, []byte(manifest), 0600); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
t.Setenv("WORKSPACE_MANIFEST_PATH", manifestPath)
|
||||
initTemplateRepoByName()
|
||||
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.PATCH("/workspaces/:id/template", h.PatchTemplate)
|
||||
|
||||
body := map[string]interface{}{"template": "ghost-template"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID+"/template", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("expected 422, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchTemplate_NotFound_Fails404(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
dir := t.TempDir()
|
||||
manifestPath := dir + "/manifest.json"
|
||||
manifest := `{"workspace_templates": [{"name": "seo-agent", "repo": "r", "ref": "main"}]}`
|
||||
if err := os.WriteFile(manifestPath, []byte(manifest), 0600); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
t.Setenv("WORKSPACE_MANIFEST_PATH", manifestPath)
|
||||
initTemplateRepoByName()
|
||||
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.PATCH("/workspaces/:id/template", h.PatchTemplate)
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
body := map[string]interface{}{"template": "seo-agent"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID+"/template", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_Runtime_ModelSecretDBError_Fails500 pins that a genuine DB error
|
||||
// reading the MODEL workspace_secret is fail-closed (500). Only sql.ErrNoRows
|
||||
// (unresolved model) skips the strict compat-check; real DB/decrypt errors
|
||||
|
||||
@@ -384,6 +384,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
return provisioner.WorkspaceConfig{
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
Template: payload.Template,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
@@ -418,34 +419,25 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
// reader's sql.ErrNoRows path was producing already.
|
||||
Image: "",
|
||||
|
||||
// RFC #2843 #24 PR-B — wire the generic template-asset
|
||||
// channel. cfg.TemplateIdentity is derived from the
|
||||
// runtime_registry (manifest.json's workspace_templates
|
||||
// entry for this runtime) — the format is "<repo>@<ref>"
|
||||
// (the giteaTemplateAssetFetcher parses this further as
|
||||
// "<owner>/<repo>@<ref>"). External-like runtimes
|
||||
// (external / kimi / kimi-cli / mock) have NO template
|
||||
// repo, so the identity is left empty — the SCAFFOLD
|
||||
// gate in collectCPConfigFiles treats empty identity as
|
||||
// "skip the fetcher" (pre-scaffold behavior preserved).
|
||||
//
|
||||
// The fetcher itself is assigned by the caller (main.go
|
||||
// for SaaS, or a test helper) via h.giteaTemplateFetcher
|
||||
// — wired here so the fetcher resolution is one place,
|
||||
// not duplicated across first-provision + restart paths.
|
||||
// nil fetcher = "no fetcher wired" (self-host default;
|
||||
// falls through to the local TemplatePath path).
|
||||
TemplateIdentity: templateIdentityForTemplateOrRuntime(conciergeTemplateOrDefault(kind, payload.Template), payload.Runtime),
|
||||
// RFC #2843 #24 PR-B + Phase 1 template decoupling: derive the
|
||||
// template identity from the installed template first, falling back
|
||||
// to the runtime's default template. For kind='platform' concierges
|
||||
// with no explicit template, force "platform-agent" so the concierge
|
||||
// persona/config is delivered (RFC §5.7; #30/#2970). The empty
|
||||
// identity tells the SCAFFOLD gate in collectCPConfigFiles to skip
|
||||
// the fetcher (external runtimes).
|
||||
TemplateIdentity: templateIdentityOrEmpty(resolveTemplateIdentity(conciergeTemplateOrDefault(kind, payload.Template), payload.Runtime)),
|
||||
TemplateAssetFetcher: h.giteaTemplateFetcher,
|
||||
}
|
||||
}
|
||||
|
||||
// templateIdentityForRuntimeOrEmpty is a tiny wrapper around
|
||||
// templateIdentityForRuntime that returns "" on miss (rather
|
||||
// than the (string, bool) tuple). Used at the call site so
|
||||
// the assignment can be a single expression.
|
||||
func templateIdentityForRuntimeOrEmpty(runtime string) string {
|
||||
id, _ := templateIdentityForRuntime(runtime)
|
||||
// templateIdentityOrEmpty is a tiny wrapper around resolveTemplateIdentity
|
||||
// that returns "" on miss (rather than the (string, bool) tuple). Used at the
|
||||
// call site so the assignment can be a single expression.
|
||||
func templateIdentityOrEmpty(id string, ok bool) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -469,7 +461,7 @@ func templateIdentityForTemplateOrRuntime(template, runtime string) string {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return templateIdentityForRuntimeOrEmpty(runtime)
|
||||
return templateIdentityOrEmpty(templateIdentityForRuntime(runtime))
|
||||
}
|
||||
|
||||
// issueAndInjectToken rotates the workspace auth token and injects the
|
||||
|
||||
@@ -315,11 +315,11 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var status, wsName, dbRuntime string
|
||||
var status, wsName, dbRuntime, dbTemplate string
|
||||
var tier int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT status, name, tier, COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1`, id,
|
||||
).Scan(&status, &wsName, &tier, &dbRuntime)
|
||||
`SELECT status, name, tier, COALESCE(runtime, 'claude-code'), COALESCE(template, '') FROM workspaces WHERE id = $1`, id,
|
||||
).Scan(&status, &wsName, &tier, &dbRuntime, &dbTemplate)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
@@ -418,7 +418,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
"runtime": containerRuntime,
|
||||
})
|
||||
|
||||
templatePath, configLabel := resolveRestartTemplate(h.configsDir, wsName, dbRuntime, restartTemplateInput{
|
||||
templatePath, configLabel := resolveRestartTemplate(h.configsDir, wsName, dbRuntime, dbTemplate, restartTemplateInput{
|
||||
Template: body.Template,
|
||||
ApplyTemplate: body.ApplyTemplate,
|
||||
RebuildConfig: body.RebuildConfig,
|
||||
@@ -442,8 +442,8 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
}
|
||||
|
||||
var configFiles map[string][]byte
|
||||
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime})
|
||||
log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime)
|
||||
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime, Template: dbTemplate})
|
||||
log.Printf("Restart: workspace %s (%s) runtime=%q template=%q", wsName, id, containerRuntime, dbTemplate)
|
||||
|
||||
// #12: ?reset=true (or body.Reset) discards the claude-sessions volume
|
||||
// before restart, giving the agent a clean /root/.claude/sessions dir.
|
||||
@@ -931,11 +931,11 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
|
||||
var wsName, status, dbRuntime string
|
||||
var wsName, status, dbRuntime, dbTemplate string
|
||||
var tier int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, status, tier, COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1 AND status NOT IN ('removed', 'paused', 'hibernated')`, workspaceID,
|
||||
).Scan(&wsName, &status, &tier, &dbRuntime)
|
||||
`SELECT name, status, tier, COALESCE(runtime, 'claude-code'), COALESCE(template, '') FROM workspaces WHERE id = $1 AND status NOT IN ('removed', 'paused', 'hibernated')`, workspaceID,
|
||||
).Scan(&wsName, &status, &tier, &dbRuntime, &dbTemplate)
|
||||
if err != nil {
|
||||
return // includes paused/hibernated — don't auto-restart those
|
||||
}
|
||||
@@ -958,7 +958,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
log.Printf("Auto-restart: restarting %s (%s) runtime=%q (was: %s)", wsName, workspaceID, dbRuntime, status)
|
||||
log.Printf("Auto-restart: restarting %s (%s) runtime=%q template=%q (was: %s)", wsName, workspaceID, dbRuntime, dbTemplate, status)
|
||||
|
||||
// #125 Phase 1: send pre-restart drain signal to the workspace agent.
|
||||
// For native_session targets, A2A messages go directly to the SDK session
|
||||
@@ -979,11 +979,11 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
log.Printf("Auto-restart: failed to set provisioning status for %s: %v", workspaceID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), workspaceID, map[string]interface{}{
|
||||
"name": wsName, "tier": tier, "runtime": dbRuntime,
|
||||
"name": wsName, "tier": tier, "runtime": dbRuntime, "template": dbTemplate,
|
||||
})
|
||||
|
||||
// Runtime from DB — no more config file parsing
|
||||
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
|
||||
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime, Template: dbTemplate})
|
||||
|
||||
// RFC#2843 #33 + SaaS restart re-stub fix: on SaaS (cpProv), restore the
|
||||
// persisted template AND resolve its LOCAL template dir so the re-provision
|
||||
@@ -1018,6 +1018,18 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RFC#2843 #33: on SaaS (cpProv), restore the persisted template so the
|
||||
// re-provision re-delivers config.yaml + prompts — TemplateIdentity is
|
||||
// derived from payload.Template (workspace_provision.go). Without this the
|
||||
// SaaS re-provision ran with template="" → 218-byte stub config + dropped
|
||||
// skills on every restart. Docker keeps its persistent config volume, so it
|
||||
// retains the "do not re-apply templates" behavior (template left empty).
|
||||
if h.cpProv != nil {
|
||||
if storedTmpl := storedWorkspaceTemplate(ctx, workspaceID); storedTmpl != "" {
|
||||
payload.Template = storedTmpl
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot restart-context data before the new session overwrites
|
||||
// last_heartbeat_at. Issue #19 Layer 1.
|
||||
restartData := loadRestartContextData(ctx, workspaceID)
|
||||
@@ -1142,11 +1154,11 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var wsName, dbRuntime string
|
||||
var wsName, dbRuntime, dbTemplate string
|
||||
var tier int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, tier, COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1 AND status = 'paused'`, id,
|
||||
).Scan(&wsName, &tier, &dbRuntime)
|
||||
`SELECT name, tier, COALESCE(runtime, 'claude-code'), COALESCE(template, '') FROM workspaces WHERE id = $1 AND status = 'paused'`, id,
|
||||
).Scan(&wsName, &tier, &dbRuntime, &dbTemplate)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found or not paused"})
|
||||
return
|
||||
@@ -1172,17 +1184,17 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
|
||||
// Collect this workspace + all paused descendants to resume
|
||||
type wsInfo struct {
|
||||
id, name, runtime string
|
||||
tier int
|
||||
id, name, runtime, template string
|
||||
tier int
|
||||
}
|
||||
toResume := []wsInfo{{id, wsName, dbRuntime, tier}}
|
||||
toResume := []wsInfo{{id, wsName, dbRuntime, dbTemplate, tier}}
|
||||
var descendantList []gin.H
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`WITH RECURSIVE descendants AS (
|
||||
SELECT id, name, tier, COALESCE(runtime, 'claude-code') AS runtime FROM workspaces WHERE parent_id = $1 AND status = 'paused'
|
||||
SELECT id, name, tier, COALESCE(runtime, 'claude-code') AS runtime, COALESCE(template, '') AS template FROM workspaces WHERE parent_id = $1 AND status = 'paused'
|
||||
UNION ALL
|
||||
SELECT w.id, w.name, w.tier, COALESCE(w.runtime, 'claude-code') FROM workspaces w JOIN descendants d ON w.parent_id = d.id WHERE w.status = 'paused'
|
||||
) SELECT id, name, tier, runtime FROM descendants`, id)
|
||||
SELECT w.id, w.name, w.tier, COALESCE(w.runtime, 'claude-code'), COALESCE(w.template, '') FROM workspaces w JOIN descendants d ON w.parent_id = d.id WHERE w.status = 'paused'
|
||||
) SELECT id, name, tier, runtime, template FROM descendants`, id)
|
||||
if err != nil {
|
||||
log.Printf("Resume: descendant query failed for %s: %v", id, err)
|
||||
}
|
||||
@@ -1190,7 +1202,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ws wsInfo
|
||||
if rows.Scan(&ws.id, &ws.name, &ws.tier, &ws.runtime) == nil {
|
||||
if rows.Scan(&ws.id, &ws.name, &ws.tier, &ws.runtime, &ws.template) == nil {
|
||||
toResume = append(toResume, ws)
|
||||
descendantList = append(descendantList, gin.H{"id": ws.id, "name": ws.name})
|
||||
}
|
||||
@@ -1216,12 +1228,14 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
log.Printf("Resume: failed to set provisioning status for %s: %v", ws.id, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
|
||||
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
|
||||
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime, "template": ws.template,
|
||||
})
|
||||
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime})
|
||||
// RFC#2843 #33: restore the persisted template on SaaS resume so config +
|
||||
// prompts re-deliver (see runRestartCycle for the full rationale).
|
||||
if h.cpProv != nil {
|
||||
// Phase 1 template decoupling: the workspace row stores the template
|
||||
// explicitly, so resume carries it through CreateWorkspacePayload.
|
||||
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime, Template: ws.template})
|
||||
// RFC#2843 #33: if the row template is empty (legacy row), restore the
|
||||
// persisted template on SaaS resume so config + prompts re-deliver.
|
||||
if payload.Template == "" && h.cpProv != nil {
|
||||
if storedTmpl := storedWorkspaceTemplate(ctx, ws.id); storedTmpl != "" {
|
||||
payload.Template = storedTmpl
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ func TestRestartHandler_RemovedWorkspaceReturns404(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-removed").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("removed", "Removed Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("removed", "Removed Agent", 1, "claude-code", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -106,8 +106,8 @@ func TestRestartHandler_AncestorPausedBlocksRestart(t *testing.T) {
|
||||
// Lookup workspace
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-grandchild").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Grandchild Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "Grandchild Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: get parent_id of grandchild -> child
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
@@ -163,8 +163,8 @@ func TestRestartHandler_ExternalRuntimeNoOps(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-external").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "External Agent", 1, "external"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "External Agent", 1, "external", ""))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
@@ -214,8 +214,8 @@ func TestRestartHandler_KimiRuntimeNoOps(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-kimi").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Kimi Agent", 1, "kimi-cli"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "Kimi Agent", 1, "kimi-cli", ""))
|
||||
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-kimi").
|
||||
@@ -259,8 +259,8 @@ func TestRestartHandler_NilProvisionerReturns503(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-no-prov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Test Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime", "template"}).
|
||||
AddRow("offline", "Test Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
@@ -528,8 +528,8 @@ func TestResumeHandler_NilProvisionerReturns503(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT name, tier, COALESCE").
|
||||
WithArgs("ws-resume-noprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime"}).
|
||||
AddRow("Test Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime", "template"}).
|
||||
AddRow("Test Agent", 1, "claude-code", ""))
|
||||
|
||||
// provisioner nil check happens BEFORE isParentPaused, so no parent query expected
|
||||
|
||||
@@ -562,8 +562,8 @@ func TestResumeHandler_DescendantsNoCascadeReturns409(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT name, tier, COALESCE").
|
||||
WithArgs("ws-resume-parent").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime"}).
|
||||
AddRow("Parent Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime", "template"}).
|
||||
AddRow("Parent Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
@@ -572,9 +572,9 @@ func TestResumeHandler_DescendantsNoCascadeReturns409(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE descendants").
|
||||
WithArgs("ws-resume-parent").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "tier", "runtime"}).
|
||||
AddRow("ws-child-1", "Child 1", 1, "claude-code").
|
||||
AddRow("ws-child-2", "Child 2", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "tier", "runtime", "template"}).
|
||||
AddRow("ws-child-1", "Child 1", 1, "claude-code", "").
|
||||
AddRow("ws-child-2", "Child 2", 1, "claude-code", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -610,8 +610,8 @@ func TestResumeHandler_DescendantsWithCascadeReturns200(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("SELECT name, tier, COALESCE").
|
||||
WithArgs("ws-resume-parent-cascade").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime"}).
|
||||
AddRow("Parent Agent", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "tier", "runtime", "template"}).
|
||||
AddRow("Parent Agent", 1, "claude-code", ""))
|
||||
|
||||
// isParentPaused: no parent
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
@@ -620,9 +620,9 @@ func TestResumeHandler_DescendantsWithCascadeReturns200(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE descendants").
|
||||
WithArgs("ws-resume-parent-cascade").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "tier", "runtime"}).
|
||||
AddRow("ws-child-1", "Child 1", 1, "claude-code").
|
||||
AddRow("ws-child-2", "Child 2", 1, "claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "tier", "runtime", "template"}).
|
||||
AddRow("ws-child-1", "Child 1", 1, "claude-code", "").
|
||||
AddRow("ws-child-2", "Child 2", 1, "claude-code", ""))
|
||||
|
||||
for _, wsID := range []string{"ws-resume-parent-cascade", "ws-child-1", "ws-child-2"} {
|
||||
mock.ExpectExec("UPDATE workspaces SET status =").
|
||||
|
||||
@@ -59,15 +59,15 @@ func (h *WorkspaceHandler) SwitchProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var status, wsName, dbRuntime, oldProvider, dataPersistence string
|
||||
var status, wsName, dbRuntime, dbTemplate, oldProvider, dataPersistence string
|
||||
var oldInstanceID sql.NullString
|
||||
var tier int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT status, name, tier, COALESCE(runtime, 'claude-code'),
|
||||
SELECT status, name, tier, COALESCE(runtime, 'claude-code'), COALESCE(template, ''),
|
||||
COALESCE(compute->>'provider', ''), COALESCE(compute->>'data_persistence', ''),
|
||||
instance_id
|
||||
FROM workspaces WHERE id = $1`, id,
|
||||
).Scan(&status, &wsName, &tier, &dbRuntime, &oldProvider, &dataPersistence, &oldInstanceID)
|
||||
).Scan(&status, &wsName, &tier, &dbRuntime, &dbTemplate, &oldProvider, &dataPersistence, &oldInstanceID)
|
||||
if err == sql.ErrNoRows || status == string(models.StatusRemoved) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
@@ -251,7 +251,7 @@ func (h *WorkspaceHandler) SwitchProvider(c *gin.Context) {
|
||||
// context: the reprovision outlives the request. Routes through
|
||||
// provisionWorkspaceAuto (not provisionWorkspaceCP directly) per
|
||||
// TestNoCallSiteCallsDirectProvisionerExceptAuto (core#2422 RCA tick).
|
||||
payload := withStoredCompute(context.Background(), id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
|
||||
payload := withStoredCompute(context.Background(), id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime, Template: dbTemplate})
|
||||
h.provisionWorkspaceAuto(id, "", nil, payload)
|
||||
|
||||
// All 5 steps completed; mark the switch COMMITTED so the rollback
|
||||
|
||||
@@ -343,7 +343,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
|
||||
// Transaction begins, workspace INSERT fails, transaction is rolled back.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
mock.ExpectRollback()
|
||||
|
||||
@@ -376,7 +376,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
// Expect workspace INSERT with defaulted tier=3 (Privileged — the
|
||||
// handler default in workspace.go), runtime="claude-code"
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "claude-code", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -428,7 +428,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -472,7 +472,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "External Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "External Agent", nil, 3, "external", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Secret inserted inside the same transaction.
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -597,7 +597,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// External URL update (localhost is explicitly allowed by validateAgentURL).
|
||||
@@ -637,7 +637,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// Pre-register flow: awaiting_agent + runtime preserved as "kimi"
|
||||
@@ -702,7 +702,7 @@ func TestWorkspaceCreate_ExternalFlagDefaultsRuntimeExternal(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "External Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "External Agent", nil, 3, "external", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("UPDATE workspaces SET status").
|
||||
@@ -1818,7 +1818,7 @@ runtime_config:
|
||||
// and hand the completed values to the INSERT.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes",
|
||||
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", "hermes-template",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
@@ -1877,7 +1877,7 @@ model: moonshot/kimi-k2.5
|
||||
// this assertion should flip back to 1.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "hermes",
|
||||
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "hermes", "legacy-template",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
@@ -1934,7 +1934,7 @@ runtime_config:
|
||||
// absence of a handler error to mean the model passthrough was honored.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes",
|
||||
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes", "hermes-template",
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
@@ -2232,7 +2232,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", "", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
|
||||
@@ -159,6 +159,7 @@ type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
@@ -295,6 +296,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
Runtime: cfg.Runtime,
|
||||
Template: cfg.Template,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"org_id": {"type": "string", "cp_consumes": true},
|
||||
"workspace_id": {"type": "string", "cp_consumes": true},
|
||||
"runtime": {"type": "string", "cp_consumes": true},
|
||||
"template": {"type": "string", "cp_consumes": true},
|
||||
"tier": {"type": "int", "cp_consumes": true},
|
||||
"instance_type": {"type": "string", "cp_consumes": true},
|
||||
"disk_gb": {"type": "int", "cp_consumes": true},
|
||||
|
||||
@@ -112,6 +112,7 @@ const (
|
||||
type WorkspaceConfig struct {
|
||||
WorkspaceID string
|
||||
TemplatePath string // Host path to template dir to copy from (e.g. claude-code-default/)
|
||||
Template string // RFC #2948 Phase 1: installed template name, distinct from engine runtime.
|
||||
TemplateIdentity string // RFC #2843 #24: opaque token the TemplateAssetFetcher resolves to the template repo+ref (e.g. "claudius-v1.2.3" or a sha). Used by SaaS; ignored by the local-dir TemplatePath path.
|
||||
ConfigFiles map[string][]byte // Generated config files to write into /configs volume
|
||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||
|
||||
@@ -527,6 +527,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
budgeth := handlers.NewBudgetHandler()
|
||||
wsAuth.GET("/budget", budgeth.GetBudget)
|
||||
r.PATCH("/workspaces/:id/budget", middleware.AdminAuth(db.DB), budgeth.PatchBudget)
|
||||
r.PATCH("/workspaces/:id/template", middleware.AdminAuth(db.DB), wh.PatchTemplate)
|
||||
|
||||
// Token management (user-facing create/list/revoke)
|
||||
tokh := handlers.NewTokenHandler()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Revert workspaces.template addition.
|
||||
ALTER TABLE workspaces
|
||||
DROP COLUMN IF EXISTS template;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Add workspaces.template to decouple installed template from engine runtime.
|
||||
-- Phase 1 of RFC #2948; closes the rejected PATCH runtime=seo-agent path.
|
||||
--
|
||||
-- Empty string means "no installed template — resolve assets from runtime".
|
||||
-- NOT NULL DEFAULT '' keeps the Go model a plain string and preserves every
|
||||
-- existing workspace without a backfill step for the column itself.
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS template TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Backfill template from workspace_config.data when it was already recorded
|
||||
-- at create time (e.g. TemplatePalette / org import flows that stored it
|
||||
-- there but did not persist it on the workspaces row).
|
||||
UPDATE workspaces w
|
||||
SET template = COALESCE(NULLIF(c.data ->> 'template', ''), w.template)
|
||||
FROM workspace_config c
|
||||
WHERE c.workspace_id = w.id
|
||||
AND w.template = ''
|
||||
AND c.data ->> 'template' IS NOT NULL;
|
||||
Reference in New Issue
Block a user