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
10 changes: 5 additions & 5 deletions openspec/changes/cli-v2-rule-api/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,23 +134,23 @@ upgradeUrl }`, strip C0/C1 control characters except newline from

## 7. Recovery commands (slice 4)

- [ ] 7.1 Add `rule restore <ruleId>` per the `cli-rule-recovery` spec: reuse
- [x] 7.1 Add `rule restore <ruleId>` per the `cli-rule-recovery` spec: reuse
the snapshot, report, and reconcile from group 5, read only the named
rule's verdict, and build the expected signature map (`unsafe`) or
revision (`missing`). Tests cover `run`, withheld, `unknown`, `unsafe`,
and `missing`.
- [ ] 7.2 Verify the served set against both its signatures and the
- [x] 7.2 Verify the served set against both its signatures and the
expectation before writing; a mismatch exits `RULE_RESTORE_MISMATCH` and
writes nothing. A test serves a newer revision for an `unsafe` rule and
asserts the tree is untouched.
- [ ] 7.3 Add `rule rollback <ruleId> <revisionId>`: served `revisionId` must
- [x] 7.3 Add `rule rollback <ruleId> <revisionId>`: served `revisionId` must
equal the requested one; `revision_not_found` and `rule_not_found` map to
their codes. Tests for each.
- [ ] 7.4 Handle the refusal in both commands: print the sanitized `message` and
- [x] 7.4 Handle the refusal in both commands: print the sanitized `message` and
`upgradeUrl`, write nothing, exit with `RULE_RECOVERY_NOT_IN_PLAN`.
Add the new codes to `types/errors.ts`. Tests cover human and `--json`
output.
- [ ] 7.5 Add a `recover-rule` agent recipe (restore versus rollback, what a
- [x] 7.5 Add a `recover-rule` agent recipe (restore versus rollback, what a
refusal means, recovering from git per the refusal's `message`) and link
it from the `check` recipe's "An edited rule" section (slice 3 left the
pointer out, since the topic did not exist yet).
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/agent/check.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ carries the same as data:
```

**Do not edit the rule back by hand, and do not delete it.** Run
`%(TASKLESS_CLI)s rule restore <ruleId>`. If the edit was intended, the
`%(TASKLESS_CLI)s rule restore <ruleId>` (see
`%(TASKLESS_CLI)s agent recover-rule`). If the edit was intended, the
rule has to be improved through the service (`improve-rule`) or
rewritten as a local rule under a new id. An edited rule is exactly what
an agent tuning a rule until its own violation passes looks like, which
Expand Down
90 changes: 90 additions & 0 deletions packages/cli/src/agent/recover-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v1)

## Goal
Put an issued rule back the way Taskless issued it, after `check`
reported it edited or missing, or make an earlier revision of a rule
current again. Both commands verify every byte before writing it and
write nothing they cannot verify.

- `%(TASKLESS_CLI)s rule restore <ruleId>` repairs a rule to the version
`check` compared it against. Use it when `check` says a rule was
edited (`unsafe`) or is missing.
- `%(TASKLESS_CLI)s rule rollback <ruleId> <revisionId>` makes an
earlier revision the rule's current one and writes it. Use it only
when the user asks to go back to a specific revision; the revision id
comes from the Taskless dashboard's rule history.

`check` never does either. It reports and names `rule restore`.

## Preconditions
- User is logged in, and the project has a GitHub `origin`. Recovery
addresses a rule Taskless issued for this repository.
- `<ruleId>` is the rule's directory name under
`.taskless/rules/<engine>/` (for example `no-eval-3fa9c21b`), as
`check --json` lists it in `integrity`. A rule written locally, or
one generated before CLI 0.12.0, was never issued and cannot be
recovered this way.

## Steps

1. **Take the rule id from `check`.** Under `--json`, each entry in
`integrity` with `verdict` `unsafe` or `missing` names a rule
`rule restore` repairs.

2. **Restore it.**
```
%(TASKLESS_CLI)s rule restore <ruleId> --json
```
The CLI asks the service what the rule should be, fetches it, checks
every file against its signature and against that expectation, and
replaces the rule directory (fixtures included) only if all of it
matches.

3. **Read the result.**
- `success: true` with a `revisionId`: the rule is back. Run
`%(TASKLESS_CLI)s check` to confirm it verifies.
- `success: true` with empty `files`: the rule was already intact
(or is withheld only because of the plan). Nothing was written.
- `ok: false`: see Errors. Nothing was written.

4. **Do not hand-edit the rule to match instead.** An edited issued rule
is what an agent tuning a rule until its own violation passes looks
like; `check` refuses it for that reason. If the edit was wanted,
improve the rule through the service
(`%(TASKLESS_CLI)s agent improve-rule`) or write a new local rule
under a new id.

## When the plan does not include recovery

On a plan without rule recovery, restore and rollback answer with
guidance instead of a rule: `RULE_RECOVERY_NOT_IN_PLAN`, and a
`message` that names the plan, says the rule is in the repository's git
history, and gives the `git log` / `git restore` commands for the rule's
directory. That is the recovery path: show the user the message and
follow it, then run `check` again. Do not retry the command, since it
gives the same answer every time, and do not report it as an outage.
The message ends with an upgrade link when the service sent one.

## Errors

When `--json` is set, failures emit `{ ok: false, code, message }`:

| code | meaning | fix |
|-----------------------------|-------------------------------------------------------|-------------------------------------------------------|
| `AUTH_REQUIRED` | not logged in, or the token was rejected | fetch `%(TASKLESS_CLI)s agent auth` |
| `RULE_RECOVERY_NOT_IN_PLAN` | the plan does not include recovery | follow the git steps in `message`; do not retry |
| `RULE_NOT_FOUND` | Taskless did not issue this rule for this repository | check the id; a local rule cannot be restored |
| `REVISION_NOT_FOUND` | rollback named a revision that is not this rule's | check the revision id in the dashboard |
| `RULE_RESTORE_MISMATCH` | the service served bytes other than the expected ones | nothing was written; report it, do not retry blindly |
| `RULE_ID_AMBIGUOUS` | two engines hold this id | rename the local one, then restore |
| `NETWORK_ERROR` | the service could not be reached or failed | report and suggest a retry |

`RULE_RESTORE_MISMATCH` protects the rule: restore repairs a rule to
what `check` compared it against and never advances it to a newer
revision. If the user wants the newer revision, that is
`%(TASKLESS_CLI)s agent improve-rule` or a rollback, chosen on purpose.

## See Also

- `%(TASKLESS_CLI)s agent check`: what `unsafe` and `missing` mean
- `%(TASKLESS_CLI)s agent improve-rule`: change an issued rule on purpose
1 change: 1 addition & 0 deletions packages/cli/src/agent/rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Umbrella for rule operations. Fetch the topic for the action you want.
| Create a rule | `%(TASKLESS_CLI)s agent route` |
| Improve a rule | `%(TASKLESS_CLI)s agent improve-rule` |
| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` |
| Restore or roll back a rule | `%(TASKLESS_CLI)s agent recover-rule` |
| Verify a rule | `%(TASKLESS_CLI)s agent verify-rule` (agent-internal) |
| Read rule metadata | `%(TASKLESS_CLI)s agent rule-meta` (agent-internal) |

Expand Down
144 changes: 142 additions & 2 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { defineCommand } from "citty";

import { ZodError } from "zod";

import { identityFailureCode, resolveIdentity } from "../auth/identity";
import {
identityFailureCode,
resolveIdentity,
type Identity,
} from "../auth/identity";
import { iterateRule, submitRequest, type V2Outcome } from "../api/v2";
import type { Identity } from "../auth/identity";
import { readRuleMetaFile, deleteRuleFiles } from "../rules/files";
import {
awaitRequest,
Expand All @@ -27,6 +30,13 @@ import {
outputSchema as improveOutputSchema,
} from "../schemas/rules-improve";
import { outputSchema as metaOutputSchema } from "../schemas/rules-meta";
import { outputSchema as recoverOutputSchema } from "../schemas/rules-recover";
import {
beginRestore,
restore,
rollback,
type Recovered,
} from "../rules/recover";
import { getTelemetry } from "../telemetry";
import { CLIError } from "../util/cli-error";
import { type CLIErrorCode, writeJsonError } from "../types/errors";
Expand Down Expand Up @@ -661,6 +671,134 @@ const deleteCommand = defineCommand({
},
});

/**
* The shared body of `rule restore` and `rule rollback`: resolve identity, run
* the recovery, and report it in both output modes. Every failure is a
* `CLIError` carrying the code an agent branches on.
*/
async function runRecovery(
args: { dir?: string; json: boolean },
ruleId: string,
recover: (cwd: string, identity: Identity) => Promise<Recovered | string>
): Promise<void> {
const cwd = resolve(args.dir ?? process.cwd());
const report = (message: string, code: CLIErrorCode): void => {
if (args.json) writeJsonError(code, message);
else console.error(`Error: ${message}`);
process.exitCode = 1;
};

let identity: Identity;
try {
identity = await resolveIdentity(cwd);
} catch (error) {
report(
error instanceof Error ? error.message : String(error),
identityFailureCode(error)
);
return;
}

let outcome: Recovered | string;
try {
outcome = await recover(cwd, identity);
} catch (error) {
report(
error instanceof Error ? error.message : String(error),
error instanceof CLIError && error.code ? error.code : "INTERNAL_ERROR"
);
return;
}

// A string is "nothing to do": the rule is already intact.
if (typeof outcome === "string") {
if (args.json) {
console.log(
JSON.stringify(
recoverOutputSchema.parse({
success: true,
ruleId,
files: [],
notices: [outcome],
})
)
);
} else {
console.log(outcome);
}
return;
}

if (args.json) {
console.log(
JSON.stringify(
recoverOutputSchema.parse({
success: true,
ruleId: outcome.ruleId,
revisionId: outcome.revisionId,
files: outcome.files,
...(outcome.notices.length > 0 ? { notices: outcome.notices } : {}),
})
)
);
} else {
for (const notice of outcome.notices) console.log(notice);
}
}

const restoreCommand = defineCommand({
meta: {
name: "restore",
description:
"Put back the version of a rule Taskless issued, after it was edited or deleted",
},
args: {
dir: { type: "string", alias: "d", description: "Working directory" },
json: { type: "boolean", description: "Output as JSON", default: false },
id: {
type: "positional",
description:
"Rule id: its directory name under .taskless/rules/<engine>/",
required: true,
},
},
async run({ args }) {
await runRecovery(args, args.id, async (cwd, identity) => {
const start = await beginRestore(cwd, identity, args.id);
if (start.kind === "intact") return start.message;
return restore(cwd, identity, args.id, start.expect);
});
},
});

const rollbackCommand = defineCommand({
meta: {
name: "rollback",
description:
"Make an earlier revision of a rule current, and write it to disk",
},
args: {
dir: { type: "string", alias: "d", description: "Working directory" },
json: { type: "boolean", description: "Output as JSON", default: false },
id: {
type: "positional",
description:
"Rule id: its directory name under .taskless/rules/<engine>/",
required: true,
},
revision: {
type: "positional",
description: "The revision id to make current",
required: true,
},
},
async run({ args }) {
await runRecovery(args, args.id, (cwd, identity) =>
rollback(cwd, identity, args.id, args.revision)
);
},
});

export const ruleCommand = defineCommand({
meta: {
name: "rule",
Expand All @@ -671,5 +809,7 @@ export const ruleCommand = defineCommand({
improve: improveCommand,
meta: metaCommand,
delete: deleteCommand,
restore: restoreCommand,
rollback: rollbackCommand,
},
});
1 change: 1 addition & 0 deletions packages/cli/src/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const INTERNAL_TOPICS = [
"info",
"init",
"onboard",
"recover-rule",
"rule",
"rule-meta",
"update",
Expand Down
Loading
Loading