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
35 changes: 35 additions & 0 deletions README.details.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,41 @@ and at most every 30 seconds of tool activity; watch daemon output for individua
live actions. Historical records without timing, or closed/changed Issues whose
stop has not been reconciled, show unavailable timing rather than zero.

#### Repairing Roc settings

`ROC_SETTINGS_INVALID` names the Roc settings location, normally
`~/.config/roc/settings.json`, and distinguishes missing files, invalid JSON,
unsupported fields, invalid cycle/settings data, and read failures.

- **Missing file:** run `npx roc-it@latest onboard` under the intended OS account.
- **Cannot read:** check that the named path is a regular file, its ownership and
read permissions, and access to its parent directories. Fix access for the
intended account; do not make credentials or settings world-readable.
- **Invalid content:** back up the exact file before editing it locally. For
example, `cp -ip ~/.config/roc/settings.json ~/.config/roc/settings.json.bak`
preserves permissions and asks before overwriting an existing backup; choose
another backup name if one already exists. Keep backups private and do not
paste settings or credentials into Issues or logs.

Repair JSON syntax first, then review unsupported fields and invalid data against
this version's format. The minimal weekly configuration is
`{"cycle":{"type":"weekly"}}`; daily uses `"daily"`, and custom uses
`{"cycle":{"type":"custom","days":14,"anchorDate":"2026-08-28"}}` with positive
whole days and a real `YYYY-MM-DD` calendar date. Optional `skills.allowlist` is an
array of `{ "name": "…", "source": "…" }` identities with nonempty strings;
`execution.allowUnsandboxed` is a boolean. Optional top-level `models` supports
only `luna`, `terra` and `sol`, each a `provider/modelId` string as described below.
Do not remove a valid `models` mapping. Other fields, including nested extras,
are rejected; review and manually correct misplaced/unsupported fields rather
than blindly deleting them or replacing the entire file with the minimal example.
Diagnostics show at most three fixed public field names and a count for hidden
names, never arbitrary unknown names or configuration values.

Roc does not rewrite invalid files, and **onboarding reads the same file and
cannot repair it**. After manual repair, retry the failed command (for example,
`npx roc-it@latest task board`). These are Roc settings, not Pi's separate
`~/.pi/agent/settings.json` or `~/.pi/agent/auth.json`; leave credentials untouched.

### Pi provider setup

Onboarding reuses Pi credentials or opens ChatGPT browser authorization. Follow
Expand Down
98 changes: 89 additions & 9 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { ZodError } from "zod";
import { type RocSettings, RocSettingsSchema } from "./domain/agile-cycle";
import { AgileError } from "./runtime/errors";
import { prepareSafeFilePath } from "./runtime/safe-file";
Expand All @@ -15,29 +16,105 @@ function isMissingSettings(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}

/** Wraps one settings read or validation failure in Roc's stable startup error. */
function invalidSettings(error: unknown): AgileError {
// Only fixed public schema names may appear in diagnostics, never arbitrary keys.
const publicFieldNames = new Set([
"cycle",
"type",
"days",
"anchorDate",
"skills",
"allowlist",
"name",
"source",
"execution",
"allowUnsandboxed",
"models",
"luna",
"terra",
"sol",
]);
const repairGuidance =
"Back up this file, then repair it manually using the supported settings format in README.details.md (Progress and recovery) and retry; onboarding reads the same file and cannot repair it.";

/** Wraps a safe location-specific diagnostic in Roc's stable startup error. */
function invalidSettings(path: string, diagnostic: string): AgileError {
return new AgileError({
code: "ROC_SETTINGS_INVALID",
category: "startup",
retryable: false,
component: "settings",
message: "Run npx roc-it@latest onboard to configure an Agile cycle",
cause: error,
message: `Roc settings at ${path}: ${diagnostic}`,
});
}

/** Summarizes schema failures with bounded public names and no values or raw errors. */
function validationDiagnostic(error: unknown): string {
if (!(error instanceof ZodError))
return "Invalid settings data; check cycle data, including calendar dates.";
const names = new Set<string>();
let hidden = 0;
let invalidCycle = false;
let invalidOther = false;
for (const issue of error.issues) {
const keys =
issue.code === "unrecognized_keys"
? issue.keys
: issue.code === "invalid_key"
? [issue.path.at(-1)]
: undefined;
if (keys) {
for (const key of keys) {
if (typeof key === "string" && publicFieldNames.has(key))
names.add(key);
else hidden++;
}
} else if (issue.path[0] === "cycle") invalidCycle = true;
else invalidOther = true;
}
const shown = [...names].sort().slice(0, 3);
hidden += names.size - shown.length;
if (hidden) shown.push(`${hidden} other field name(s) hidden`);
return [
shown.length ? `Unsupported fields: ${shown.join(", ")}.` : "",
invalidCycle
? "Invalid cycle data; use daily, weekly, or custom with positive whole days and a valid anchorDate (YYYY-MM-DD)."
: "",
invalidOther
? "Invalid settings data; check the supported settings types and structure."
: "",
]
.filter(Boolean)
.join(" ");
}

/** Loads strict settings for repeat onboarding, returning undefined only when absent. */
export async function loadRocSettingsIfPresent(
homeRoot = homedir(),
): Promise<RocSettings | undefined> {
const path = rocSettingsPath(homeRoot);
let source: string;
try {
return RocSettingsSchema.parse(
JSON.parse(await readFile(rocSettingsPath(homeRoot), "utf8")),
);
source = await readFile(path, "utf8");
} catch (error) {
if (isMissingSettings(error)) return undefined;
throw invalidSettings(error);
throw invalidSettings(
path,
"Could not read settings. Check that the path is a regular file and that your account has file and parent-directory permissions; retry after fixing access.",
);
}
let input: unknown;
try {
input = JSON.parse(source);
} catch {
throw invalidSettings(path, `Invalid JSON. ${repairGuidance}`);
}
try {
return RocSettingsSchema.parse(input);
} catch (error) {
throw invalidSettings(
path,
`${validationDiagnostic(error)} ${repairGuidance}`,
);
}
}

Expand All @@ -47,7 +124,10 @@ export async function loadRocSettings(
): Promise<RocSettings> {
const settings = await loadRocSettingsIfPresent(homeRoot);
if (settings !== undefined) return settings;
throw invalidSettings(new Error("Roc settings do not exist"));
throw invalidSettings(
rocSettingsPath(homeRoot),
"Settings file is missing. Run npx roc-it@latest onboard to configure an Agile cycle",
);
}

/** Validates and safely writes Roc's global settings. */
Expand Down
57 changes: 56 additions & 1 deletion test/cli/github-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from "node:path";
import { runCli } from "../../src/cli/run";
import type { CliRuntime, SchedulerRunInput } from "../../src/cli/types";
import { githubTaskSnapshot } from "../../src/github/execution-view";
import { saveRocSettings } from "../../src/settings";
import { rocSettingsPath, saveRocSettings } from "../../src/settings";
import { memoryGitHub } from "../helpers/github-native";

test("public task reads use GitHub, preserve legacy files, and scheduler rejects the removed local queue", async () => {
Expand Down Expand Up @@ -86,6 +86,61 @@ test("public task reads use GitHub, preserve legacy files, and scheduler rejects
}
});

test("task board explains invalid settings before remote reads and opens after manual repair", async () => {
const root = await mkdtemp(join(tmpdir(), "roc-board-settings-"));
const remote = memoryGitHub();
const output: string[] = [];
const errors: string[] = [];
let reads = 0;
const runtime: CliRuntime = {
projectRoot: root,
homeRoot: root,
now: () => new Date("2026-09-08T00:00:00Z"),
async runScheduler() {},
async readTasks() {
reads++;
const data = await remote.store().list();
return githubTaskSnapshot(data.tasks, data.diagnostics);
},
};
const io = {
out: (line: string) => output.push(line),
err: (line: string) => errors.push(line),
};
try {
const path = await saveRocSettings({ cycle: { type: "weekly" } }, root);
// Top-level models is supported; type is unsupported at the settings root.
const invalid = Buffer.from(
'{"cycle":{"type":"weekly"},"type":"SECRET_VALUE"}\n',
);
await writeFile(path, invalid);
expect(await runCli(["task", "board"], io, runtime)).toBe(1);
expect(reads).toBe(0);
expect(output).toEqual([]);
const diagnostic = errors.join("\n");
expect(diagnostic).toContain("ROC_SETTINGS_INVALID");
expect(diagnostic).toContain(rocSettingsPath(root));
expect(diagnostic).toContain("Unsupported fields: type.");
expect(diagnostic).toContain("Back up this file");
expect(diagnostic).not.toContain("Run npx roc-it@latest onboard");
expect(diagnostic).not.toContain("SECRET_VALUE");
expect(await readFile(path)).toEqual(invalid);

await saveRocSettings(
{ cycle: { type: "weekly" }, models: { luna: "provider/model" } },
root,
);
errors.length = 0;
expect(await runCli(["task", "board"], io, runtime)).toBe(0);
expect(reads).toBe(1);
expect(output.join("\n")).toContain("GitHub checkpoints");
expect(output.join("\n")).toContain("Return 42");
expect(errors).toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("task acceptance reads recorded criteria without mutating GitHub state", async () => {
const root = await mkdtemp(join(tmpdir(), "roc-task-acceptance-"));
const remote = memoryGitHub();
Expand Down
45 changes: 44 additions & 1 deletion test/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,13 +892,56 @@ test("cycle current explains how to create missing settings", async () => {
),
).toBe(1);
expect(errors).toEqual([
"Run npx roc-it@latest onboard to configure an Agile cycle",
`Roc settings at ${rocSettingsPath(homeRoot)}: Settings file is missing. Run npx roc-it@latest onboard to configure an Agile cycle`,
]);
} finally {
await rm(homeRoot, { recursive: true, force: true });
}
});

test("onboarding preserves invalid settings and requests manual repair before model setup", async () => {
const root = await mkdtemp(join(tmpdir(), "roc-onboard-invalid-"));
const home = await mkdtemp(join(tmpdir(), "roc-onboard-invalid-home-"));
let modelCalls = 0;
try {
const path = await saveRocSettings({ cycle: { type: "weekly" } }, home);
for (const source of [
'{"cycle":{"type":"weekly"},"type":"SECRET_VALUE"}\n',
'{"cycle":{"type":"SECRET_VALUE",',
]) {
const before = Buffer.from(source);
await writeFile(path, before);
const { io, output, errors } = interactiveIo(["2"]);
expect(
await runCli(
["onboard"],
io,
onboardingRuntime({
projectRoot: root,
homeRoot: home,
configureModel: async () => {
modelCalls++;
return "provider/model";
},
}),
),
).toBe(1);
expect(modelCalls).toBe(0);
expect(await readFile(path)).toEqual(before);
expect(errors.join("\n")).toContain(rocSettingsPath(home));
expect(errors.join("\n")).toContain("Back up this file");
expect(errors.join("\n")).toContain(
"onboarding reads the same file and cannot repair it",
);
expect(errors.join("\n")).not.toContain("SECRET_VALUE");
expect(output.join("\n")).not.toContain("Result: Complete");
}
} finally {
await rm(root, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
});

test("execution refusal and model failure preserve Roc settings and never report ready", async () => {
for (const allow of [false, true]) {
const root = await mkdtemp(join(tmpdir(), "roc-onboard-failed-"));
Expand Down
Loading
Loading