Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 30 additions & 2 deletions packages/loopover-contract/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,42 @@ export type ProposeActionClass = (typeof PROPOSE_ACTION_CLASSES)[number];
export const TEST_FRAMEWORKS = ["vitest", "jest", "pytest", "go-test", "rspec", "cargo-test"] as const;
export type TestFramework = (typeof TEST_FRAMEWORKS)[number];

/** Lifecycle states of a plan step in the stateless plan-DAG tools. */
export const PLAN_STEP_STATUSES = ["pending", "in_progress", "completed", "skipped", "failed"] as const;
/**
* Lifecycle states of a plan step.
*
* One vocabulary, deliberately: the remote server's stateless plan-DAG tools (loopover_build_plan /
* plan_status / record_step_result) and the miner's own plan store hold the same steps at different
* points in their life, and a step written by one and read by the other must round-trip.
*
* This list said `in_progress` where both real surfaces say `running` until #9518. Nothing consumed
* it yet, so nothing broke -- but the first consumer would have rejected every running step the
* plan store has ever persisted.
*/
export const PLAN_STEP_STATUSES = ["pending", "running", "completed", "failed", "skipped"] as const;
export type PlanStepStatus = (typeof PLAN_STEP_STATUSES)[number];

/** Verdicts the pre-start feasibility surfaces return. */
export const FEASIBILITY_VERDICTS = ["go", "raise", "avoid"] as const;
export type FeasibilityVerdict = (typeof FEASIBILITY_VERDICTS)[number];

/**
* Why LoopOver's automated reviewer deliberately stayed quiet on a PR -- the filter vocabulary the
* skipped-PR audit accepts. Mirrors `PUBLIC_SURFACE_SKIP_REASONS`
* (src/signals/settings-preview.ts), pinned against it by a meta-test for the same reason the
* autonomy enums above are: this package cannot import the Worker's `src/`, and a filter value that
* silently stops matching is worse than one that fails loudly.
*/
export const PUBLIC_SURFACE_SKIP_REASONS = [
"surface_off",
"missing_author",
"bot_author",
"ignored_author",
"maintainer_author",
"miner_detection_unavailable",
"not_official_gittensor_miner",
] as const;
export type PublicSurfaceSkipReason = (typeof PUBLIC_SURFACE_SKIP_REASONS)[number];

/**
* Scope selector for WRITING the self-hosted private config: the deployment-wide default layer, or
* one repository's override layer. Only real files are writable.
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export {
} from "./tool-definition.js";

export * from "./enums.js";
export { PREFLIGHT_LIMITS, PREDICT_GATE_MAX_CHANGED_PATHS, PREDICT_GATE_MAX_CHANGED_PATH_CHARS } from "./limits.js";
export { PREFLIGHT_LIMITS, PREDICT_GATE_MAX_CHANGED_PATHS, PREDICT_GATE_MAX_CHANGED_PATH_CHARS, WRITE_TOOL_LIMITS, SCENARIO_LIMITS } from "./limits.js";
export { ownerRepoInput, ownerRepoPullInput, freshnessFields, toolErrorFields } from "./shared.js";
export { TOOL_CONTRACTS, listToolDefinitions, getToolContract } from "./tools/index.js";
export {
Expand Down
27 changes: 27 additions & 0 deletions packages/loopover-contract/src/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,30 @@ export const PREDICT_GATE_MAX_CHANGED_PATHS = 500;
* servers accepts today, which is the one thing an input schema may never do.
*/
export const PREDICT_GATE_MAX_CHANGED_PATH_CHARS = 400;

/**
* Bounds on the local-execution write-spec tools' free text (#780).
*
* These tools never perform a write -- they return a spec the caller runs with its own credentials
* -- so the bounds are about keeping a spec small enough to be reviewable before it is executed,
* not about protecting a database column. `bodyChars` matches GitHub's own 65536-character comment
* ceiling with headroom for the wrapper the spec builder adds.
*/
export const WRITE_TOOL_LIMITS = {
titleChars: 400,
bodyChars: 60_000,
branchChars: 255,
targetFiles: 50,
} as const;

/**
* Repo and branch identifier bounds.
*
* Restated from src/scenarios/input-model.ts for the same reason PREFLIGHT_LIMITS is restated from
* the engine -- this package is a zod-only leaf and cannot import the Worker's `src/` -- and pinned
* against it by a meta-test so the two cannot drift.
*/
export const SCENARIO_LIMITS = {
repoFullNameChars: 200,
branchRefChars: 200,
} as const;
107 changes: 106 additions & 1 deletion packages/loopover-contract/src/tools/admin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// Node entry ever fills.
import { z } from "zod";
import { defineTool } from "../tool-definition.js";
import { CONFIG_ADMIN_READ_SCOPES } from "../enums.js";
import { CONFIG_ADMIN_READ_SCOPES, CONFIG_ADMIN_WRITE_SCOPES } from "../enums.js";

/**
* `repoFullName` is CONDITIONALLY required -- mandatory for scope "effective" and "repo", ignored
Expand Down Expand Up @@ -57,3 +57,108 @@ export const adminGetConfigTool = defineTool({
input: AdminGetConfigInput,
output: AdminGetConfigOutput,
});

// ── write config ────────────────────────────────────────────────────────────────────────────────

/** Unlike the read tool, write scope is only ever "global" | "repo" -- there is no single file an
* "effective" write could target, since that view is a merge of several layers. */
export const AdminWriteConfigInput = z.object({
scope: z.enum(CONFIG_ADMIN_WRITE_SCOPES),
repoFullName: z.string().min(3).max(200).optional(),
content: z.string().max(256 * 1024),
dryRun: z.boolean().optional(),
});

export const AdminWriteConfigOutput = z.looseObject({
configured: z.boolean(),
ok: z.boolean().optional(),
dryRun: z.boolean().optional(),
path: z.string().optional(),
backupPath: z.string().nullable().optional(),
error: z.string().optional(),
});

export type AdminWriteConfigInput = z.infer<typeof AdminWriteConfigInput>;
export type AdminWriteConfigOutput = z.infer<typeof AdminWriteConfigOutput>;

export const adminWriteConfigTool = defineTool({
name: "loopover_admin_write_config",
title: "Write private instance config",
description:
"Self-hosted-operator only. Write this instance's own private global-default or per-repo .loopover.yml config: validated, a timestamped backup of any existing file first, atomic write. Set dryRun=true to validate without writing. Requires LOOPOVER_MCP_ADMIN_TOKEN. The config mount stays read-only (:ro) by default in docker-compose.yml -- an operator must flip it to :rw themselves before a real (non-dry-run) write can succeed.",
category: "admin",
auth: "mcp-admin",
locality: "remote",
availability: "selfhost",
annotations: { readOnlyHint: false, destructiveHint: true },
input: AdminWriteConfigInput,
output: AdminWriteConfigOutput,
});

// ── list config backups ─────────────────────────────────────────────────────────────────────────

export const AdminListConfigBackupsInput = z.object({
scope: z.enum(CONFIG_ADMIN_WRITE_SCOPES),
repoFullName: z.string().min(3).max(200).optional(),
});

export const AdminListConfigBackupsOutput = z.looseObject({
configured: z.boolean(),
backups: z.array(z.looseObject({ name: z.string(), path: z.string(), mtimeMs: z.number() })).optional(),
});

export type AdminListConfigBackupsInput = z.infer<typeof AdminListConfigBackupsInput>;
export type AdminListConfigBackupsOutput = z.infer<typeof AdminListConfigBackupsOutput>;

export const adminListConfigBackupsTool = defineTool({
name: "loopover_admin_list_config_backups",
title: "List private config backups",
description:
"Self-hosted-operator only. List timestamped backups (newest first) created by loopover_admin_write_config for the global-default or a specific repo's config. Requires LOOPOVER_MCP_ADMIN_TOKEN.",
category: "admin",
auth: "mcp-admin",
locality: "remote",
availability: "selfhost",
input: AdminListConfigBackupsInput,
output: AdminListConfigBackupsOutput,
});

// ── trigger redeploy ────────────────────────────────────────────────────────────────────────────

export const AdminTriggerRedeployInput = z.object({
// #7723: the same character class deploy-selfhost-image.sh's own validate_inputs enforces (no
// whitespace/quote/backslash/compose-interpolation/shell-metacharacter chars) -- redundant with
// both that script's own check and the companion's own isSafeImageOverride, but a caller gets a
// clear MCP-level error instead of an opaque host-side rejection two hops away.
image: z
.string()
.min(1)
.max(512)
.regex(/^[^\s"'\\${}`;|&<>]+$/, "must not contain whitespace, quotes, backslashes, compose interpolation, or shell metacharacters")
.optional(),
});

export const AdminTriggerRedeployOutput = z.looseObject({
configured: z.boolean(),
ok: z.boolean().optional(),
exitCode: z.number().nullable().optional(),
log: z.array(z.string()).optional(),
error: z.string().optional(),
});

export type AdminTriggerRedeployInput = z.infer<typeof AdminTriggerRedeployInput>;
export type AdminTriggerRedeployOutput = z.infer<typeof AdminTriggerRedeployOutput>;

export const adminTriggerRedeployTool = defineTool({
name: "loopover_admin_trigger_redeploy",
title: "Trigger instance redeploy",
description:
"Self-hosted-operator only. Trigger a real redeploy of this instance (pull the published image, restart, wait for health) via the host-side redeploy companion (#7723) -- NOT via the Docker socket, which is never mounted into this container. Optional `image` pins a specific tag/digest; omitted uses the companion's own default (the currently-configured LOOPOVER_IMAGE). Requires LOOPOVER_MCP_ADMIN_TOKEN. Returns configured=false if REDEPLOY_COMPANION_TOKEN is unset or the companion isn't reachable at REDEPLOY_COMPANION_SOCKET_PATH -- see systemd/loopover-redeploy-companion.service.example to set it up. A real redeploy restarts this very process; the tool call itself completes (with the companion's full log) before that restart happens, since the companion waits for the new container to report healthy before responding.",
category: "admin",
auth: "mcp-admin",
locality: "remote",
availability: "selfhost",
annotations: { readOnlyHint: false, destructiveHint: true },
input: AdminTriggerRedeployInput,
output: AdminTriggerRedeployOutput,
});
Loading
Loading