feat: structured __renderer envelopes for MCP tools
All 19 oikos tools now return JSON envelopes with __renderer hints
(terminal, service_status, ping, path_report, etc.) carrying structured
data alongside the model-facing prose in a `message` field — agent
behavior is unchanged while the UI renders native cards.
Fixes: ping_service SQL (json||text precedence), timestamptz scan,
run:{...} action prefix parsing, get_execution_status target slug join.
Also: deploy.sh step 5.5 restarts dsh web after API deploy; watchdog
LaunchAgent detects API recovery and kickstarts dsh web for stale MCP
sessions.
This commit is contained in:
@@ -19,6 +19,23 @@ import (
|
|||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// lxcStateEnvelope runs pct status on the Proxmox host and wraps the output
|
||||||
|
// in a terminal envelope labelled with the LXC slug.
|
||||||
|
func lxcStateEnvelope(ctx context.Context, host, user, slug, pveID string) *mcp.CallToolResult {
|
||||||
|
cmd := fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)
|
||||||
|
out, err := sshExec(ctx, host, user, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err))
|
||||||
|
}
|
||||||
|
return rendererEnvelope("terminal", terminalEnvelope{
|
||||||
|
Target: slug,
|
||||||
|
Command: cmd,
|
||||||
|
State: "done",
|
||||||
|
Output: out,
|
||||||
|
Message: fmt.Sprintf("Resource state of %s from its Proxmox host.", slug),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.ExecutionService) []toolReg {
|
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.ExecutionService) []toolReg {
|
||||||
return []toolReg{
|
return []toolReg{
|
||||||
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||||
@@ -137,80 +154,133 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
targets = append(targets, s)
|
targets = append(targets, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results := inspectPathAcrossTargets(ctx, pool, path, targets)
|
results := inspectPathAcrossTargets(ctx, pool, path, targets)
|
||||||
out, _ := json.MarshalIndent(results, "", " ")
|
return rendererEnvelope("path_report", map[string]any{
|
||||||
return textResult(string(out)), nil
|
"path": path,
|
||||||
|
"targets": results,
|
||||||
|
}), nil
|
||||||
}},
|
}},
|
||||||
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||||
),
|
),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
execID, _ := args["execution_id"].(string)
|
execID, _ := args["execution_id"].(string)
|
||||||
if execID == "" {
|
if execID == "" {
|
||||||
return textResult("execution_id required"), nil
|
return textResult("execution_id required"), nil
|
||||||
|
}
|
||||||
|
eid, err := uuid.Parse(execID)
|
||||||
|
if err != nil {
|
||||||
|
// Try finding by exec slug prefix
|
||||||
|
var found uuid.UUID
|
||||||
|
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||||
}
|
}
|
||||||
eid, err := uuid.Parse(execID)
|
eid = found
|
||||||
if err != nil {
|
}
|
||||||
// Try finding by exec slug prefix
|
rows, err := pool.Query(ctx, `
|
||||||
var found uuid.UUID
|
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
||||||
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
e.result::text, e.duration_ms, e.started_at::text,
|
||||||
if err2 != nil {
|
e.completed_at::text, e.correlation_id,
|
||||||
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
COALESCE(t.slug, '')
|
||||||
}
|
FROM executions e
|
||||||
eid = found
|
LEFT JOIN entities t ON t.id = e.target_entity_id
|
||||||
}
|
WHERE e.entity_id = $1`, eid)
|
||||||
return queryRows(ctx, pool, `
|
if err != nil {
|
||||||
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||||
e.result::text, e.duration_ms, e.started_at::text,
|
}
|
||||||
e.completed_at::text, e.correlation_id
|
defer rows.Close()
|
||||||
FROM executions e
|
if !rows.Next() {
|
||||||
WHERE e.entity_id = $1`, eid), nil
|
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||||
}},
|
}
|
||||||
|
var entityID, action, riskClass, status, result, started, completed, correlation, targetSlug string
|
||||||
|
var duration any
|
||||||
|
if err := rows.Scan(&entityID, &action, &riskClass, &status, &result, &duration, &started, &completed, &correlation, &targetSlug); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||||
|
}
|
||||||
|
env := executionTerminalEnvelope(action, riskClass, status, result)
|
||||||
|
env.Target = targetSlug
|
||||||
|
env.ExecID = entityID
|
||||||
|
if s, ok := duration.(int64); ok {
|
||||||
|
env.Message += fmt.Sprintf(" Duration: %dms.", s)
|
||||||
|
}
|
||||||
|
return rendererEnvelope("terminal", env), nil
|
||||||
|
}},
|
||||||
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
||||||
prop{"lines", "integer", "Number of lines (default 50)"}),
|
prop{"lines", "integer", "Number of lines (default 50)"}),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["service_slug"].(string)
|
slug, _ := args["service_slug"].(string)
|
||||||
n := int(getFloat(args, "lines", 50))
|
n := int(getFloat(args, "lines", 50))
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
return textResult("service_slug is required"), nil
|
return textResult("service_slug is required"), nil
|
||||||
}
|
}
|
||||||
host, user, err := resolveHost(ctx, pool, slug)
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
}
|
}
|
||||||
svc := strings.TrimPrefix(slug, "lxc:")
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
|
cmd := fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)
|
||||||
if err != nil {
|
out, err := sshExec(ctx, host, user, cmd)
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
if err != nil {
|
||||||
}
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
return textResult(out), nil
|
}
|
||||||
}},
|
return rendererEnvelope("terminal", terminalEnvelope{
|
||||||
|
Target: slug,
|
||||||
|
Command: cmd,
|
||||||
|
State: "done",
|
||||||
|
Output: out,
|
||||||
|
Message: fmt.Sprintf("Last %d log lines of %s.", n, slug),
|
||||||
|
}), nil
|
||||||
|
}},
|
||||||
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["service_slug"].(string)
|
slug, _ := args["service_slug"].(string)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
return textResult("service_slug is required"), nil
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
|
out, err := sshExec(ctx, host, user,
|
||||||
|
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
// systemctl output: line 1 is-active, line 2 is-enabled, then
|
||||||
|
// Key=Value pairs from show. "could not be found" on either of
|
||||||
|
// the first lines means the unit does not exist.
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
active, enabled := lines[0], ""
|
||||||
|
if len(lines) > 1 {
|
||||||
|
enabled = lines[1]
|
||||||
|
}
|
||||||
|
status := map[string]string{
|
||||||
|
"service": slug,
|
||||||
|
"active": active,
|
||||||
|
"enabled": enabled,
|
||||||
|
}
|
||||||
|
for _, l := range lines[2:] {
|
||||||
|
if k, v, ok := strings.Cut(l, "="); ok {
|
||||||
|
switch k {
|
||||||
|
case "ActiveEnterTimestamp":
|
||||||
|
status["since"] = v
|
||||||
|
case "SubState":
|
||||||
|
status["sub_state"] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
host, user, err := resolveHost(ctx, pool, slug)
|
}
|
||||||
if err != nil {
|
return rendererEnvelope("service_status", status), nil
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
}},
|
||||||
}
|
|
||||||
svc := strings.TrimPrefix(slug, "lxc:")
|
|
||||||
out, err := sshExec(ctx, host, user,
|
|
||||||
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
}},
|
|
||||||
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
||||||
@@ -240,29 +310,21 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
if err != nil || hostSlug == "" {
|
if err != nil || hostSlug == "" {
|
||||||
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
||||||
}
|
}
|
||||||
var host, user string
|
var host, user string
|
||||||
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
|
||||||
}
|
|
||||||
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
|
||||||
if err2 != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
}
|
|
||||||
var hostSlug string
|
|
||||||
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
|
||||||
host, user, err := resolveHost(ctx, pool, hostSlug)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||||
}
|
}
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
return lxcStateEnvelope(ctx, host, user, slug, pveID), nil
|
||||||
if err != nil {
|
}
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
var hostSlug string
|
||||||
}
|
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
||||||
return textResult(out), nil
|
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||||
}},
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
return lxcStateEnvelope(ctx, host, user, slug, pveID), nil
|
||||||
|
}},
|
||||||
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe",
|
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe",
|
||||||
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -271,17 +333,17 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
if slug == "" {
|
if slug == "" {
|
||||||
return textResult("service_slug is required"), nil
|
return textResult("service_slug is required"), nil
|
||||||
}
|
}
|
||||||
rows, err := pool.Query(ctx, `
|
rows, err := pool.Query(ctx, `
|
||||||
SELECT st.health, st.last_check_at,
|
SELECT st.health, st.last_check_at::text,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
e.attributes->>'url',
|
e.attributes->>'url',
|
||||||
CASE WHEN e.attributes->>'public_host' IS NOT NULL
|
CASE WHEN e.attributes->>'public_host' IS NOT NULL
|
||||||
THEN 'https://' || e.attributes->>'public_host'
|
THEN 'https://' || (e.attributes->>'public_host')
|
||||||
END
|
END
|
||||||
) AS url
|
) AS url
|
||||||
FROM entity_status st
|
FROM entity_status st
|
||||||
JOIN entities e ON e.id = st.entity_id
|
JOIN entities e ON e.id = st.entity_id
|
||||||
WHERE e.slug = $1`, slug)
|
WHERE e.slug = $1`, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
||||||
}
|
}
|
||||||
@@ -289,20 +351,29 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
|
|||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
||||||
}
|
}
|
||||||
var health, lastCheck, url string
|
var health, lastCheck, url string
|
||||||
rows.Scan(&health, &lastCheck, &url)
|
if err := rows.Scan(&health, &lastCheck, &url); err != nil {
|
||||||
if url == "" {
|
return textResult(fmt.Sprintf("scan error: %v", err)), nil
|
||||||
return textResult(fmt.Sprintf("health=%s last_check=%s url=no-url (entity has no url or public_host attribute)", health, lastCheck)), nil
|
|
||||||
}
|
}
|
||||||
// Live HTTP probe — HEAD request to check current state
|
if url == "" {
|
||||||
code := "n/a"
|
return rendererEnvelope("ping", map[string]string{
|
||||||
if resp, err := http.Head(url); err == nil {
|
"service": slug, "health": health, "last_check": lastCheck,
|
||||||
resp.Body.Close()
|
"url": "", "http": "",
|
||||||
code = fmt.Sprintf("%d", resp.StatusCode)
|
"note": "entity has no url or public_host attribute",
|
||||||
} else {
|
}), nil
|
||||||
code = fmt.Sprintf("err: %v", err)
|
}
|
||||||
}
|
// Live HTTP probe — HEAD request to check current state
|
||||||
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s http=%s", health, lastCheck, url, code)), nil
|
code := ""
|
||||||
|
if resp, err := http.Head(url); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
code = fmt.Sprintf("%d", resp.StatusCode)
|
||||||
|
} else {
|
||||||
|
code = fmt.Sprintf("err: %v", err)
|
||||||
|
}
|
||||||
|
return rendererEnvelope("ping", map[string]string{
|
||||||
|
"service": slug, "health": health, "last_check": lastCheck,
|
||||||
|
"url": url, "http": code,
|
||||||
|
}), nil
|
||||||
}},
|
}},
|
||||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -450,12 +450,86 @@ func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.Call
|
|||||||
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
wrapper := map[string]any{
|
return rendererEnvelope(rendererID, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rendererEnvelope wraps any JSON-serializable payload in the __renderer
|
||||||
|
// envelope oikos-ui dispatches on. data may be a row list (array) or a single
|
||||||
|
// structured object; callers that only have prose keep textResult.
|
||||||
|
func rendererEnvelope(rendererID string, data any) *mcp.CallToolResult {
|
||||||
|
payload, err := json.MarshalIndent(map[string]any{
|
||||||
"__renderer": rendererID,
|
"__renderer": rendererID,
|
||||||
"data": items,
|
"data": data,
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("%v", data))
|
||||||
}
|
}
|
||||||
data, _ := json.MarshalIndent(wrapper, "", " ")
|
return textResult(string(payload))
|
||||||
return textResult(string(data))
|
}
|
||||||
|
|
||||||
|
// terminalEnvelope is the structured shape of a command-execution result:
|
||||||
|
// everything a terminal card needs (target, command, combined output, state)
|
||||||
|
// plus the model-facing prose in Message, so restructuring the result for the
|
||||||
|
// UI never changes what the agent reads.
|
||||||
|
type terminalEnvelope struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
State string `json:"state"` // queued | started | done | error | refused
|
||||||
|
Risk string `json:"risk,omitempty"`
|
||||||
|
ExecID string `json:"exec_id,omitempty"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
NeedsApproval bool `json:"needs_approval,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// executionTerminalEnvelope builds the terminal envelope for a stored
|
||||||
|
// execution row: command/purpose from the action JSON, output/error from the
|
||||||
|
// result JSON, state from the execution status.
|
||||||
|
func executionTerminalEnvelope(action, riskClass, status, result string) terminalEnvelope {
|
||||||
|
env := terminalEnvelope{
|
||||||
|
Command: action,
|
||||||
|
Risk: riskClass,
|
||||||
|
State: "started",
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
var act struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
}
|
||||||
|
// The stored action prefixes the JSON payload with the tool kind
|
||||||
|
// (`run:{...}`); parse from the first '{'.
|
||||||
|
if i := strings.Index(action, "{"); i >= 0 {
|
||||||
|
if err := json.Unmarshal([]byte(action[i:]), &act); err == nil && act.Command != "" {
|
||||||
|
env.Command = act.Command
|
||||||
|
if act.Purpose != "" {
|
||||||
|
env.Message = "Purpose: " + act.Purpose + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "completed":
|
||||||
|
env.State = "done"
|
||||||
|
case "failed":
|
||||||
|
env.State = "error"
|
||||||
|
case "cancelled":
|
||||||
|
env.State = "refused"
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(result), &res); err == nil {
|
||||||
|
env.Output = res.Output
|
||||||
|
env.Error = res.Error
|
||||||
|
} else if result != "" {
|
||||||
|
env.Output = result
|
||||||
|
}
|
||||||
|
env.Message += fmt.Sprintf("Execution %s: %s.", status, status)
|
||||||
|
if env.Error != "" {
|
||||||
|
env.Message += " Error: " + env.Error
|
||||||
|
}
|
||||||
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -650,24 +724,41 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionS
|
|||||||
return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
|
return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return renderSubmit(targetSlug, command, res)
|
return renderSubmit(targetSlug, command, purpose, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing text,
|
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing result: a
|
||||||
// preserving the exact pre-service message shapes.
|
// terminal envelope whose Message keeps the exact pre-service prose (the
|
||||||
func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
// agent's operating instructions live there), with the structured fields the
|
||||||
|
// terminal card renders.
|
||||||
|
func renderSubmit(targetSlug, command, purpose string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
||||||
d := res.Decision
|
d := res.Decision
|
||||||
|
env := terminalEnvelope{
|
||||||
|
Target: targetSlug,
|
||||||
|
Command: command,
|
||||||
|
State: "unknown",
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
if purpose != "" {
|
||||||
|
env.Message = "Purpose: " + purpose + "\n"
|
||||||
|
}
|
||||||
switch d.Action {
|
switch d.Action {
|
||||||
case app.DecisionRefuse:
|
case app.DecisionRefuse:
|
||||||
return textResult(d.Message)
|
env.State = "refused"
|
||||||
|
env.Error = d.Message
|
||||||
|
env.Message += d.Message
|
||||||
|
|
||||||
case app.DecisionQueue:
|
case app.DecisionQueue:
|
||||||
confirmNote := ""
|
confirmNote := ""
|
||||||
if d.RiskClass == policy.RiskDestructive {
|
if d.RiskClass == policy.RiskDestructive {
|
||||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||||
}
|
}
|
||||||
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
env.State = "queued"
|
||||||
targetSlug, d.RiskClass, res.ExecutionID, confirmNote))
|
env.Risk = string(d.RiskClass)
|
||||||
|
env.ExecID = string(res.ExecutionID)
|
||||||
|
env.NeedsApproval = true
|
||||||
|
env.Message += fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||||
|
targetSlug, d.RiskClass, res.ExecutionID, confirmNote)
|
||||||
|
|
||||||
case app.DecisionAuto:
|
case app.DecisionAuto:
|
||||||
label := d.RiskClass
|
label := d.RiskClass
|
||||||
@@ -677,6 +768,8 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
|||||||
case "destructive":
|
case "destructive":
|
||||||
label = "destructive"
|
label = "destructive"
|
||||||
}
|
}
|
||||||
|
env.Risk = string(label)
|
||||||
|
env.ExecID = string(res.ExecutionID)
|
||||||
if res.AsyncStarted {
|
if res.AsyncStarted {
|
||||||
via := ""
|
via := ""
|
||||||
switch d.AutoViaWindow {
|
switch d.AutoViaWindow {
|
||||||
@@ -687,11 +780,17 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
|||||||
default:
|
default:
|
||||||
via = ", async"
|
via = ", async"
|
||||||
}
|
}
|
||||||
return textResult(fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
env.State = "started"
|
||||||
targetSlug, label, via, res.ExecutionID, res.ExecutionID))
|
env.Message += fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||||
|
targetSlug, label, via, res.ExecutionID, res.ExecutionID)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if res.Err != nil {
|
if res.Err != nil {
|
||||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output))
|
env.State = "error"
|
||||||
|
env.Error = res.Err.Error()
|
||||||
|
env.Output = res.Output
|
||||||
|
env.Message += fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
via := ", auto"
|
via := ", auto"
|
||||||
switch d.AutoViaWindow {
|
switch d.AutoViaWindow {
|
||||||
@@ -700,9 +799,14 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
|||||||
case "destructive":
|
case "destructive":
|
||||||
via = ", auto via confirmed-target window"
|
via = ", auto via confirmed-target window"
|
||||||
}
|
}
|
||||||
return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output))
|
env.State = "done"
|
||||||
|
env.Output = res.Output
|
||||||
|
env.Message += fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output)
|
||||||
|
|
||||||
|
default:
|
||||||
|
env.Message += fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action)
|
||||||
}
|
}
|
||||||
return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action))
|
return rendererEnvelope("terminal", env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoApprove updates the approval + execution status in the DB to approved,
|
// autoApprove updates the approval + execution status in the DB to approved,
|
||||||
|
|||||||
179
scripts/deploy-plugins.sh
Normal file → Executable file
179
scripts/deploy-plugins.sh
Normal file → Executable file
@@ -1,57 +1,166 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Oikos-plugins deploy script — triggered by Gitea webhook on push to dtoro/oikos-plugins.
|
# Oikos-plugins deploy script — triggered by Gitea webhook on push to dtoro/oikos-plugins.
|
||||||
# Runs on mac-mini via launchd unit running cmd/webhook (route: /deploy-plugins).
|
# Runs on mac-mini via launchd unit network.hubris.oikos-deploy-webhook (route: /deploy-plugins).
|
||||||
|
#
|
||||||
|
# Deployment vehicle is the in-tree clone at $DSH_DIR/packages/oikos: the web
|
||||||
|
# profile symlinks its packages, and their @deepseek-ai peer deps resolve
|
||||||
|
# through the harness workspace root node_modules. UI and node halves ship as
|
||||||
|
# committed lib/ artifacts, so no build step runs here. On failure the script
|
||||||
|
# restores the previous SHAs and notifies via the oikos API (OIKOS_API_TOKEN)
|
||||||
|
# and Matrix (MATRIX_WEBHOOK_URL) when configured.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
REPO_DIR="${REPO_DIR:-$HOME/Projects/oikos}"
|
|
||||||
PLUGIN_DIR="${PLUGIN_DIR:-$HOME/oikos-plugins}"
|
|
||||||
DSH_DIR="${DSH_DIR:-$HOME/Projects/deepseek-harness}"
|
DSH_DIR="${DSH_DIR:-$HOME/Projects/deepseek-harness}"
|
||||||
|
PLUGIN_DIR="${PLUGIN_DIR:-$DSH_DIR/packages/oikos}"
|
||||||
PROFILE_DIR="${PROFILE_DIR:-$HOME/.dsh/profiles/web}"
|
PROFILE_DIR="${PROFILE_DIR:-$HOME/.dsh/profiles/web}"
|
||||||
PORT="${PORT:-3080}"
|
PORT="${PORT:-3080}"
|
||||||
LOCKDIR="${LOCKDIR:-/tmp/oikos-plugins-deploy.lock}"
|
LOCKDIR="${LOCKDIR:-/tmp/oikos-plugins-deploy.lock}"
|
||||||
|
DSH_BRANCH="${DSH_BRANCH:-master}"
|
||||||
|
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:$PORT/}"
|
||||||
|
RETRIES=${RETRIES:-90}
|
||||||
|
ROLLBACK_RETRIES=${ROLLBACK_RETRIES:-30}
|
||||||
|
SLEEP=${SLEEP:-2}
|
||||||
|
AGENT_LABEL="network.hubris.dsh-web"
|
||||||
|
UID_N=$(id -u)
|
||||||
|
|
||||||
acquire_lock() {
|
notify_deploy_failure() {
|
||||||
if mkdir "$LOCKDIR" 2>/dev/null; then
|
reason="$1"
|
||||||
trap 'rm -rf "$LOCKDIR"' EXIT
|
echo "NOTIFY: deploy failed — $reason"
|
||||||
return 0
|
if [ -n "${OIKOS_API_TOKEN:-}" ]; then
|
||||||
|
curl -sf -X POST "http://localhost:8090/api/v1/events" \
|
||||||
|
-H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"type\":\"deploy.failed\",\"severity\":\"critical\",\"source\":\"webhook\",\"data\":{\"repo\":\"oikos-plugins\",\"reason\":\"$reason\"}}" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [ -n "${MATRIX_WEBHOOK_URL:-}" ]; then
|
||||||
|
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"msgtype\":\"m.text\",\"body\":\"🚨 oikos-plugins deploy failed: $reason\"}" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
echo "deploy already running, skipping"
|
|
||||||
exit 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acquire_lock
|
# Serialize deploys. mkdir is atomic on POSIX (macOS lacks flock); the
|
||||||
|
# stale-pid check recovers a lock left by a SIGKILLed or rebooted deploy.
|
||||||
|
if ! mkdir "$LOCKDIR" 2>/dev/null; then
|
||||||
|
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
|
||||||
|
echo "deploy already in progress (pid $oldpid) — exiting"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "removing stale deploy lock (pid ${oldpid:-?} not running)"
|
||||||
|
rm -rf "$LOCKDIR"
|
||||||
|
mkdir "$LOCKDIR"
|
||||||
|
fi
|
||||||
|
echo $$ > "$LOCKDIR/pid"
|
||||||
|
trap 'rc=$?; rm -rf "$LOCKDIR" 2>/dev/null || true; if [ "$_ok" != "1" ] && [ "$_notified" != "1" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT
|
||||||
|
_ok=0
|
||||||
|
_notified=0
|
||||||
|
|
||||||
|
# Any HTTP response counts as healthy: this probes liveness (is the port
|
||||||
|
# serving), not a specific route.
|
||||||
|
wait_healthy() {
|
||||||
|
tries="$1"
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$tries" ]; do
|
||||||
|
if curl -s -o /dev/null --max-time 2 "$HEALTH_URL"; then
|
||||||
|
echo "healthy after $((i * SLEEP))s"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep "$SLEEP"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_dsh() {
|
||||||
|
if ! launchctl print "gui/$UID_N/$AGENT_LABEL" >/dev/null 2>&1; then
|
||||||
|
launchctl bootstrap "gui/$UID_N" "$HOME/Library/LaunchAgents/$AGENT_LABEL.plist"
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
launchctl kickstart -k "gui/$UID_N/$AGENT_LABEL"
|
||||||
|
}
|
||||||
|
|
||||||
echo "=== oikos-plugins deploy started ==="
|
echo "=== oikos-plugins deploy started ==="
|
||||||
|
|
||||||
# 1. Pull latest
|
[ -d "$PLUGIN_DIR/.git" ] || {
|
||||||
if [ ! -d "$PLUGIN_DIR" ]; then
|
echo "ERROR: $PLUGIN_DIR is not a git clone of dtoro/oikos-plugins — refusing"
|
||||||
git clone gitea@git-ssh.hubris.network:dtoro/oikos-plugins.git "$PLUGIN_DIR"
|
exit 1
|
||||||
fi
|
}
|
||||||
cd "$PLUGIN_DIR"
|
|
||||||
git fetch origin master
|
|
||||||
git reset --hard origin/master
|
|
||||||
|
|
||||||
# 2. Symlink packages into dsh profile
|
PLUGIN_OLD=$(git -C "$PLUGIN_DIR" rev-parse HEAD 2>/dev/null || echo "")
|
||||||
|
DSH_OLD=$(git -C "$DSH_DIR" rev-parse HEAD 2>/dev/null || echo "")
|
||||||
|
echo "pre-deploy: plugins=${PLUGIN_OLD:-none} dsh=${DSH_OLD:-none}"
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
_notified=1
|
||||||
|
echo "=== rolling back ==="
|
||||||
|
if [ -n "$PLUGIN_OLD" ] && [ "$(git -C "$PLUGIN_DIR" rev-parse HEAD)" != "$PLUGIN_OLD" ]; then
|
||||||
|
git -C "$PLUGIN_DIR" reset --hard "$PLUGIN_OLD"
|
||||||
|
fi
|
||||||
|
if [ -n "$DSH_OLD" ] && [ "$(git -C "$DSH_DIR" rev-parse HEAD)" != "$DSH_OLD" ]; then
|
||||||
|
git -C "$DSH_DIR" reset --hard "$DSH_OLD"
|
||||||
|
pnpm -C "$DSH_DIR" install --no-frozen-lockfile >/dev/null 2>&1 || true
|
||||||
|
git -C "$DSH_DIR" checkout -- pnpm-lock.yaml 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
restart_dsh
|
||||||
|
if wait_healthy "$ROLLBACK_RETRIES"; then
|
||||||
|
notify_deploy_failure "deploy failed; rolled back to plugins@${PLUGIN_OLD:-?} dsh@${DSH_OLD:-?}"
|
||||||
|
else
|
||||||
|
notify_deploy_failure "deploy failed and rollback unhealthy — dsh web DOWN on port $PORT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Plugins: advance the in-tree clone to the pushed state. Uncommitted local
|
||||||
|
# edits mean someone is developing here — never destroy them; deploy the dirty
|
||||||
|
# tree as-is and say so.
|
||||||
|
git -C "$PLUGIN_DIR" fetch origin master
|
||||||
|
if git -C "$PLUGIN_DIR" diff --quiet && git -C "$PLUGIN_DIR" diff --cached --quiet; then
|
||||||
|
git -C "$PLUGIN_DIR" reset --hard origin/master
|
||||||
|
echo "plugins at $(git -C "$PLUGIN_DIR" rev-parse --short HEAD)"
|
||||||
|
else
|
||||||
|
echo "WARN: $PLUGIN_DIR has uncommitted changes — deploying the dirty tree as-is"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Harness: advance by fast-forward only (never discards local commits;
|
||||||
|
# refuses when diverged). The untracked packages/oikos member is invisible to
|
||||||
|
# origin/master's lockfile, so install non-frozen and restore the lockfile
|
||||||
|
# afterwards: node_modules keeps the resolution, the tree stays clean.
|
||||||
|
git -C "$DSH_DIR" fetch origin "$DSH_BRANCH"
|
||||||
|
git -C "$DSH_DIR" pull --ff-only origin "$DSH_BRANCH"
|
||||||
|
pnpm -C "$DSH_DIR" install --no-frozen-lockfile
|
||||||
|
git -C "$DSH_DIR" checkout -- pnpm-lock.yaml
|
||||||
|
echo "dsh at $(git -C "$DSH_DIR" rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
# 2b. Rebuild the frontend dist: the web-app bundle serves the gitignored
|
||||||
|
# apps/web dist through workspace exports, so a harness advance without a
|
||||||
|
# rebuild keeps serving the previous UI.
|
||||||
|
pnpm -C "$DSH_DIR" --filter @deepseek-ai/dsh-web-frontend run build >/dev/null
|
||||||
|
echo "frontend dist rebuilt"
|
||||||
|
|
||||||
|
# 3. Symlink packages into dsh profile
|
||||||
for pkg in ui mcp-scope session-summary bundle evals; do
|
for pkg in ui mcp-scope session-summary bundle evals; do
|
||||||
name=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$pkg/package.json')).name)")
|
name=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PLUGIN_DIR/$pkg/package.json')).name)")
|
||||||
ln -sf "$PLUGIN_DIR/$pkg" "$PROFILE_DIR/node_modules/$name"
|
ln -sf "$PLUGIN_DIR/$pkg" "$PROFILE_DIR/node_modules/$name"
|
||||||
echo "linked $name"
|
echo "linked $name"
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3. Build UI client bundle (needs dsh workspace for tsdown)
|
# 4. Patch migration: the oikos overlay used to live only in /tmp (wiped on
|
||||||
cd "$DSH_DIR"
|
# reboot). If the profile patch layer is still empty and the /tmp copy exists,
|
||||||
pnpm install --filter @deepseek-ai/dsh-oikos-ui --frozen-lockfile 2>&1
|
# move it into the profile so launchd boots need no --patch flag.
|
||||||
cd "$PLUGIN_DIR/ui"
|
if [ -f /tmp/oikos-mcp-patch.yml ] && ! grep -q 'id:' "$PROFILE_DIR/cordis.patch.yml" 2>/dev/null; then
|
||||||
DSH_BUILD_FACE=client npx tsdown --config tsdown.config.ts 2>&1
|
cp /tmp/oikos-mcp-patch.yml "$PROFILE_DIR/cordis.patch.yml"
|
||||||
echo "UI bundle built"
|
echo "migrated oikos patch into $PROFILE_DIR/cordis.patch.yml"
|
||||||
|
|
||||||
# 4. Restart dsh
|
|
||||||
DASHBOARD_PID=$(pgrep -f 'dsh.*--port.*3080' 2>/dev/null || true)
|
|
||||||
if [ -n "$DASHBOARD_PID" ]; then
|
|
||||||
kill "$DASHBOARD_PID" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
fi
|
||||||
cd "$DSH_DIR"
|
|
||||||
nohup pnpm dsh --profile web --patch /tmp/oikos-mcp-patch.yml --port "$PORT" > /tmp/dsh-web.log 2>&1 &
|
|
||||||
echo "dsh restarted (pid $!)"
|
|
||||||
|
|
||||||
echo "=== oikos-plugins deploy complete ==="
|
# 5. Restart under launchd and health-check
|
||||||
|
restart_dsh
|
||||||
|
if ! wait_healthy "$RETRIES"; then
|
||||||
|
echo "ERROR: health check failed after $((RETRIES * SLEEP))s"
|
||||||
|
rollback
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ok=1
|
||||||
|
echo "=== oikos-plugins deploy complete ==="
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ DOCKER_BUILDKIT=1 docker compose --profile "$PROFILE" build \
|
|||||||
# ── 5. Rolling restart ────────────────────────────────────────────────────
|
# ── 5. Rolling restart ────────────────────────────────────────────────────
|
||||||
echo "[5/8] docker compose up -d"
|
echo "[5/8] docker compose up -d"
|
||||||
docker compose --profile "$PROFILE" up -d --remove-orphans
|
docker compose --profile "$PROFILE" up -d --remove-orphans
|
||||||
|
echo "[5.5/8] restarting dsh web (stale MCP session after api restart)"
|
||||||
|
launchctl kickstart -k "gui/$(id -u)/network.hubris.dsh-web" 2>/dev/null || true
|
||||||
|
|
||||||
# ── 6. Prune old image tags — keep the 3 newest per service so rollback ────
|
# ── 6. Prune old image tags — keep the 3 newest per service so rollback ────
|
||||||
# (OIKOS_VERSION=v0.x.y docker compose up) stays available. The repo list
|
# (OIKOS_VERSION=v0.x.y docker compose up) stays available. The repo list
|
||||||
|
|||||||
36
scripts/network.hubris.dsh-web.plist
Normal file
36
scripts/network.hubris.dsh-web.plist
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>network.hubris.dsh-web</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/opt/homebrew/bin/pnpm</string>
|
||||||
|
<string>dsh</string>
|
||||||
|
<string>--profile</string>
|
||||||
|
<string>web</string>
|
||||||
|
<string>--port</string>
|
||||||
|
<string>3080</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/Users/dtoro/Projects/deepseek-harness</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>/Users/dtoro</string>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/Users/dtoro/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/dtoro/Library/Logs/dsh-web.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/dtoro/Library/Logs/dsh-web.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
20
scripts/network.hubris.oikos-api-watchdog.plist
Normal file
20
scripts/network.hubris.oikos-api-watchdog.plist
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>network.hubris.oikos-api-watchdog</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/Users/dtoro/Projects/deepseek-harness/packages/oikos/scripts/oikos-api-health-watchdog.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<false/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/dtoro/Library/Logs/oikos-api-watchdog.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/dtoro/Library/Logs/oikos-api-watchdog.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
22
scripts/oikos-api-health-watchdog.sh
Executable file
22
scripts/oikos-api-health-watchdog.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Oikos API health watchdog — if the oikos API restarts while dsh web holds a
|
||||||
|
# stale MCP session, every tool call fails with "session not found" until dsh
|
||||||
|
# web itself restarts. This monitor detects the transition and triggers a
|
||||||
|
# launchd kickstart so the connection recovers automatically.
|
||||||
|
#
|
||||||
|
# Runs every 30s via LaunchAgent network.hubris.oikos-api-watchdog.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
API_URL="${API_URL:-http://localhost:8090/healthz}"
|
||||||
|
MARKER="${MARKER:-/tmp/.oikos-api-down}"
|
||||||
|
|
||||||
|
if curl -sf -o /dev/null --max-time 3 "$API_URL"; then
|
||||||
|
if [ -f "$MARKER" ]; then
|
||||||
|
rm -f "$MARKER"
|
||||||
|
echo "oikos API recovered — restarting dsh web"
|
||||||
|
launchctl kickstart -k "gui/$(id -u)/network.hubris.dsh-web" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
touch "$MARKER"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user