mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat: refresh opted-in seat skills on restore for Claude and Codex (#2626)
Adds an opt-in on_restore policy to resources.skills[] entries in agent specs. keep_saved, the default and what an absent policy means, keeps #2337's behaviour: a restored seat keeps its saved skill copy. follow_source refreshes the seat's copy of that skill from its source when the seat is restored, for Claude and Codex seats, keeping the native conversation. Copies edited, linked or not owned by OpenRig in the seat keep their existing protections. An unrecognized on_restore value adds an advisory that names the resource, the value and the two supported spellings, and is normalized to keep_saved rather than invalidating the spec. On a no-op restore a keep_saved skill reads only the projection record, and the following bookkeeping is best effort, so it can't fail a launch. Documented in docs/reference/agent-spec.md. Requested by seanmcn in #2625.
This commit is contained in:
1 parent
6a46172df1
commit
46ce2bd860
17 files changed
+1260
-69
No files matched your search
@@ -372,7 +372,7 @@ version.
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
|
||||
| `skills` | `id`, `path`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
|
||||
@@ -439,6 +439,55 @@ a legacy table it names the file to move the settings into.
|
||||
- Resource IDs must be unique within their category
|
||||
- Resource IDs are the identifiers used in `uses` references
|
||||
|
||||
### Skill Resources
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- id: house-style
|
||||
path: skills/house-style
|
||||
on_restore: follow_source # optional — keep_saved by default
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
|
||||
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
|
||||
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
|
||||
|
||||
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
|
||||
|
||||
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
|
||||
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
spec as it is now, using the saved launch recipe and rig folder, and:
|
||||
|
||||
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
|
||||
restore puts it back as usual;
|
||||
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
|
||||
- removes a skill that followed its source and that the profile no longer selects.
|
||||
|
||||
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
|
||||
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
|
||||
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
|
||||
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
|
||||
retain their byte-only ownership checks until a new projection or refresh records permissions.
|
||||
|
||||
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
|
||||
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
|
||||
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
|
||||
|
||||
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
|
||||
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
|
||||
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
|
||||
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
|
||||
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
|
||||
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
|
||||
already read into the retained conversation.
|
||||
|
||||
Following covers the whole selected skill folder, including scripts and references. It does not install external
|
||||
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
|
||||
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
|
||||
not applied by this refresh.
|
||||
|
||||
### Guidance Resources
|
||||
|
||||
```yaml
|
||||
@@ -494,6 +543,7 @@ restore behavior, and the distinction between readiness and verified proof.
|
||||
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
|
||||
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
|
||||
11. `runtime_resources` entries require `runtime` and `type` fields.
|
||||
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
|
||||
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
|
||||
a plugin instead.
|
||||
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
|
||||
|
||||
@@ -31,7 +31,7 @@ import { unresolvedClaudePermissionModes } from "../domain/native-permission-sel
|
||||
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
|
||||
import { contextUsageDirectory, providerUsageDirectory } from "../domain/telemetry-state-paths.js";
|
||||
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs } from "../domain/seat-launch-additions.js";
|
||||
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
|
||||
import { recordUnchangedSeatSkillFollowing, recordSeatSkillFollowing, recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
|
||||
|
||||
export interface ClaudeAdapterFsOps {
|
||||
readFile(path: string): string;
|
||||
@@ -169,12 +169,15 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const failed: Array<{ effectiveId: string; error: string }> = [];
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
if (entry.classification === "no_op") {
|
||||
skipped.push(entry.effectiveId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (entry.classification === "no_op") {
|
||||
if (entry.category === "skill" && binding.seatSkillsRoot && !binding.seatSkillsRefused) {
|
||||
recordUnchangedSeatSkillFollowing(binding.seatSkillsRoot, entry.effectiveId,
|
||||
entry.onRestore === "follow_source" ? entry.absolutePath : undefined);
|
||||
}
|
||||
skipped.push(entry.effectiveId);
|
||||
continue;
|
||||
}
|
||||
const didProject = this.projectEntry(entry, binding.cwd, binding.claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused);
|
||||
if (didProject) {
|
||||
projected.push(entry.effectiveId);
|
||||
@@ -835,6 +838,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return { ok: false, error: "Claude resume failed: timed out waiting for Claude to become active", probe: probeEvidence };
|
||||
}
|
||||
|
||||
/** Copy one selected resource, preserving source modes and recording owned seat-skill files after writing. */
|
||||
private projectEntry(entry: ProjectionEntry, cwd: string, managedBlockFile: ClaudeManagedBlockFile, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string): boolean {
|
||||
// A skipped fragment is reported skipped; it never falls through to the generic extensions copy.
|
||||
const handled = entry.category === "runtime_resource" ? this.applyRuntimeResource(entry, cwd, warnings) : false;
|
||||
@@ -895,16 +899,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const refusedDest = seatSkillsRoot && entry.category === "skill" ? seatSkillWriteRefusal(seatSkillsRoot, dest) : undefined;
|
||||
if (refusedDest) { warnings.push(`${entry.effectiveId}: ${refusedDest}`); notProjected.push(dest); continue; }
|
||||
const content = this.fs.readFile(src);
|
||||
record(dest, src, content);
|
||||
// Reconcile mode even when the content write is skipped: a byte-identical dest
|
||||
// projected earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(dest) && hashContent(content) === hashContent(this.fs.readFile(dest))) {
|
||||
this.preserveMode(src, dest);
|
||||
record(dest, src, content);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(dest));
|
||||
this.fs.writeFile(dest, content);
|
||||
this.preserveMode(src, dest);
|
||||
record(dest, src, content);
|
||||
}
|
||||
const pluginLoss = seatSkillsRefused ? seatPluginSkillsLossNote(entry.effectiveId, skippedPluginSkills, seatSkillsRefused) : undefined;
|
||||
if (pluginLoss) warnings.push(pluginLoss);
|
||||
@@ -914,15 +919,22 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const refusedFile = seatSkillsRoot && entry.category === "skill" ? seatSkillWriteRefusal(seatSkillsRoot, destFile) : undefined;
|
||||
if (refusedFile) { warnings.push(`${entry.effectiveId}: ${refusedFile}`); return false; }
|
||||
const content = this.fs.readFile(entry.absolutePath);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
if (this.fs.exists(destFile) && hashContent(content) === hashContent(this.fs.readFile(destFile))) {
|
||||
this.preserveMode(entry.absolutePath, destFile);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
return true;
|
||||
}
|
||||
this.fs.writeFile(destFile, content);
|
||||
this.preserveMode(entry.absolutePath, destFile);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
}
|
||||
return seatProjectionComplete(entry.effectiveId, notProjected, warnings);
|
||||
const complete = seatProjectionComplete(entry.effectiveId, notProjected, warnings);
|
||||
// A skill whose resource says `on_restore: follow_source` is recorded as following its source, so a restore that
|
||||
// keeps the conversation brings it up to date (and removes it once no longer selected); any other is unmarked.
|
||||
if (complete && seatSkillsRoot && entry.category === "skill") {
|
||||
recordSeatSkillFollowing(seatSkillsRoot, entry.effectiveId, entry.onRestore === "follow_source" ? entry.absolutePath : undefined);
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
|
||||
};
|
||||
|
||||
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
|
||||
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "../domain/seat-skill-storage.js";
|
||||
import { recordUnchangedSeatSkillFollowing, recordSeatSkillFollowing, recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "../domain/seat-skill-storage.js";
|
||||
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
|
||||
import { findTomlTable, opensTomlTable, parseTomlRoot, tomlArrayTableHeaderPath, tomlDefines, tomlTableHeaderPath } from "../domain/toml-tables.js";
|
||||
|
||||
@@ -808,12 +808,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const failed: Array<{ effectiveId: string; error: string }> = [];
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
if (entry.classification === "no_op") {
|
||||
skipped.push(entry.effectiveId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (entry.classification === "no_op") {
|
||||
if (entry.category === "skill" && binding.seatSkillsRoot && !binding.seatSkillsRefused) {
|
||||
recordUnchangedSeatSkillFollowing(binding.seatSkillsRoot, entry.effectiveId,
|
||||
entry.onRestore === "follow_source" ? entry.absolutePath : undefined);
|
||||
}
|
||||
skipped.push(entry.effectiveId);
|
||||
continue;
|
||||
}
|
||||
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding), binding);
|
||||
if (didProject) {
|
||||
projected.push(entry.effectiveId);
|
||||
@@ -1391,6 +1394,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
this.provisionWorkspaceTrust(binding.cwd ?? null, this.homeFor(binding));
|
||||
}
|
||||
|
||||
/** Copy one selected resource, preserving source modes and recording owned seat-skill files after writing. */
|
||||
private projectEntry(entry: ProjectionEntry, cwd: string, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string,
|
||||
home?: string, binding?: NodeBinding): boolean {
|
||||
if (entry.category === "runtime_resource" && this.applyRuntimeResource(entry, home)) {
|
||||
@@ -1451,11 +1455,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const refusedDest = seatSkillsRoot && entry.category === "skill" ? seatSkillWriteRefusal(seatSkillsRoot, dest) : undefined;
|
||||
if (refusedDest) { warnings.push(`${entry.effectiveId}: ${refusedDest}`); notProjected.push(dest); continue; }
|
||||
const content = this.fs.readFile(src);
|
||||
record(dest, src, content);
|
||||
// Reconcile mode even when the content write is skipped: a byte-identical dest
|
||||
// projected earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(dest) && hashContent(content) === hashContent(this.fs.readFile(dest))) {
|
||||
this.preserveMode(src, dest);
|
||||
record(dest, src, content);
|
||||
continue;
|
||||
}
|
||||
const existed = this.fs.exists(dest);
|
||||
@@ -1463,6 +1467,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
this.fs.writeFile(dest, content);
|
||||
if (!existed) createdFiles.push(dest);
|
||||
this.preserveMode(src, dest);
|
||||
record(dest, src, content);
|
||||
}
|
||||
const pluginLoss = seatSkillsRefused ? seatPluginSkillsLossNote(entry.effectiveId, skippedPluginSkills, seatSkillsRefused) : undefined;
|
||||
if (pluginLoss) warnings.push(pluginLoss);
|
||||
@@ -1471,20 +1476,27 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const refusedFile = seatSkillsRoot && entry.category === "skill" ? seatSkillWriteRefusal(seatSkillsRoot, destFile) : undefined;
|
||||
if (refusedFile) { warnings.push(`${entry.effectiveId}: ${refusedFile}`); return false; }
|
||||
const content = this.fs.readFile(entry.absolutePath);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
if (this.fs.exists(destFile) && hashContent(content) === hashContent(this.fs.readFile(destFile))) {
|
||||
this.preserveMode(entry.absolutePath, destFile);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
return true;
|
||||
}
|
||||
const existed = this.fs.exists(destFile);
|
||||
this.fs.writeFile(destFile, content);
|
||||
if (!existed) createdFiles.push(destFile);
|
||||
this.preserveMode(entry.absolutePath, destFile);
|
||||
record(destFile, entry.absolutePath, content);
|
||||
}
|
||||
} finally {
|
||||
if (entry.category === "plugin") warnings.push(...excludeNewGeneratedFiles(cwd, createdFiles));
|
||||
}
|
||||
return seatProjectionComplete(entry.effectiveId, notProjected, warnings);
|
||||
const complete = seatProjectionComplete(entry.effectiveId, notProjected, warnings);
|
||||
// A skill whose resource says `on_restore: follow_source` is recorded as following its source, so a restore that
|
||||
// keeps the conversation brings it up to date (and removes it once no longer selected); any other is unmarked.
|
||||
if (complete && seatSkillsRoot && entry.category === "skill") {
|
||||
recordSeatSkillFollowing(seatSkillsRoot, entry.effectiveId, entry.onRestore === "follow_source" ? entry.absolutePath : undefined);
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -279,6 +279,15 @@ export function validateAgentSpec(raw: unknown): ValidationResult {
|
||||
allLocalIds[category] = new Set(mappingEntries.map((e) => e["id"] as string).filter(Boolean));
|
||||
}
|
||||
|
||||
if (category === "skills") {
|
||||
entries.forEach((entry, i) => {
|
||||
const onRestore = entry && typeof entry === "object" ? entry["on_restore"] : undefined;
|
||||
if (onRestore !== undefined && onRestore !== "keep_saved" && onRestore !== "follow_source") {
|
||||
advisories.push(`resources.skills[${i}].on_restore: unknown value "${String(onRestore)}"; expected "keep_saved" | "follow_source"; using "keep_saved"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// runtime_resources must have runtime field
|
||||
if (category === "runtime_resources") {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
@@ -451,6 +460,7 @@ function normalizeLifecycle(raw: Record<string, unknown>): LifecycleDefaults {
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize validated resource declarations, retaining each skill's optional restore policy. */
|
||||
function normalizeResources(raw: unknown): AgentResources {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { skills: [], guidance: [], subagents: [], plugins: [], runtimeResources: [] };
|
||||
@@ -459,7 +469,8 @@ function normalizeResources(raw: unknown): AgentResources {
|
||||
|
||||
return {
|
||||
skills: Array.isArray(obj["skills"])
|
||||
? (obj["skills"] as Record<string, unknown>[]).map((s) => ({ id: s["id"] as string, path: s["path"] as string }))
|
||||
? (obj["skills"] as Record<string, unknown>[]).map((s) => ({ id: s["id"] as string, path: s["path"] as string,
|
||||
onRestore: s["on_restore"] === "follow_source" ? "follow_source" : "keep_saved" }))
|
||||
: [],
|
||||
guidance: Array.isArray(obj["guidance"])
|
||||
? (obj["guidance"] as Record<string, unknown>[]).map((g) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { OPENRIG_HOME } from "../openrig-compat.js";
|
||||
import type { StartupBlock } from "./types.js";
|
||||
import type { SkillOnRestore, SkillResource, StartupBlock } from "./types.js";
|
||||
import { classifyResourceProjection } from "./conflict-detector.js";
|
||||
import type { ResolvedNodeConfig, QualifiedResource, ResolvedResources } from "./profile-resolver.js";
|
||||
import type { ResourceCollision } from "./agent-resolver.js";
|
||||
@@ -37,6 +37,9 @@ export interface ProjectionEntry {
|
||||
* and projects only when its runtime-specific manifest is present
|
||||
*/
|
||||
pluginType?: "claude" | "codex" | "auto";
|
||||
/** Skills only, set when the resource says `on_restore: follow_source`: a restore that keeps the seat's
|
||||
* conversation takes this skill's source as it is then, instead of the saved copy. */
|
||||
onRestore?: SkillOnRestore;
|
||||
}
|
||||
|
||||
export interface ProjectionPlan {
|
||||
@@ -152,6 +155,10 @@ export function planProjection(input: ProjectionInput): PlanResult {
|
||||
classification: "safe_projection",
|
||||
};
|
||||
|
||||
if (catKey === "skills" && (qr.resource as SkillResource).onRestore === "follow_source") {
|
||||
entry.onRestore = "follow_source";
|
||||
}
|
||||
|
||||
if (catKey === "runtimeResources") {
|
||||
entry.resourceType = (qr.resource as { type?: string }).type;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-i
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
import { effortPrecedence, readSeatEffort, type LaunchEffortSelection } from "./seat-effort.js";
|
||||
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { ensureSeatSkillPlugin, hasSeatSkillRecords, rehydrateSeatSkills, seatSkillPaths, seatSkillPathsForNode, seatCatalogKeptNote, seatSkillReplay, writeSeatSkillCatalog, type SeatSkillRehydration, writeSeatSkillRouter, seatRouterKeptNote } from "./seat-skill-storage.js";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { ensureSeatSkillPlugin, followSeatSkills, hasFollowingSeatSkills, hasSeatSkillRecords, rehydrateSeatSkills, seatSkillPaths, seatSkillPathsForNode, seatCatalogKeptNote, seatSkillReplay, writeSeatSkillCatalog, type SeatSkillRehydration, type SeatSkillSelection, writeSeatSkillRouter, seatRouterKeptNote } from "./seat-skill-storage.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { NativePermissionStore } from "./native-permission-store.js";
|
||||
import { readSeatClaudeMcp, type ClaudeMcp } from "./claude-mcp.js";
|
||||
@@ -224,7 +224,7 @@ export interface NarrowLaunchResult {
|
||||
};
|
||||
}
|
||||
|
||||
interface RestoreOrchestratorDeps {
|
||||
export interface RestoreOrchestratorDeps {
|
||||
db: Database.Database;
|
||||
rigRepo: RigRepository;
|
||||
sessionRegistry: SessionRegistry;
|
||||
@@ -251,6 +251,9 @@ interface RestoreOrchestratorDeps {
|
||||
}>;
|
||||
/** Return whether a persisted fresh launch can be attempted without discarding the current pane. */
|
||||
freshLaunchAvailable?: (rigId: string, nodeId: string) => boolean;
|
||||
/** A seat's skill selection as its spec says today (from its launch recipe), so skills that follow their source
|
||||
* (`on_restore: follow_source`) come back with today's bytes and today's selection. Absent: saved selection only. */
|
||||
seatSkillSelection?: (rigId: string, nodeId: string) => Promise<{ ok: true; selection: SeatSkillSelection } | { ok: false; reason: string }>;
|
||||
/** Where the person's seat-skills line answer is read (default: the OpenRig settings). */
|
||||
routerLineSettings?: Pick<SettingsStore, "resolveOne">;
|
||||
/** Read-only current authored selection for Claude activity hooks (the
|
||||
@@ -279,6 +282,7 @@ export class RestoreOrchestrator {
|
||||
private listProcesses: (() => Promise<Array<{ pid: number; ppid: number; command: string }>>) | undefined;
|
||||
private freshLaunch: RestoreOrchestratorDeps["freshLaunch"];
|
||||
private freshLaunchAvailable: RestoreOrchestratorDeps["freshLaunchAvailable"];
|
||||
private seatSkillSelection: RestoreOrchestratorDeps["seatSkillSelection"];
|
||||
private routerLineSettings: Pick<SettingsStore, "resolveOne"> | undefined;
|
||||
private getDesiredClaudeActivityHooks: RestoreOrchestratorDeps["getDesiredClaudeActivityHooks"];
|
||||
private appliedLaunchStore: AppliedLaunchObservationStore;
|
||||
@@ -323,6 +327,7 @@ export class RestoreOrchestrator {
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.freshLaunch = deps.freshLaunch;
|
||||
this.freshLaunchAvailable = deps.freshLaunchAvailable;
|
||||
this.seatSkillSelection = deps.seatSkillSelection;
|
||||
this.getDesiredClaudeActivityHooks = deps.getDesiredClaudeActivityHooks;
|
||||
this.routerLineSettings = deps.routerLineSettings;
|
||||
this.appliedLaunchStore = new AppliedLaunchObservationStore(deps.db);
|
||||
@@ -1321,14 +1326,31 @@ export class RestoreOrchestrator {
|
||||
// OPR.0.7.0.13: a Claude seat's team skills come back before its session starts, because the session's plugin list
|
||||
// is set then. `rig down` removed the unchanged files OpenRig wrote but kept the records, so they are put back from
|
||||
// their recorded sources (only where those still match) and the seat's plugin is written again. An edited or
|
||||
// unprovable file is named, never substituted. Nothing is replayed into a resumed conversation; this is the seat's
|
||||
// storage on disk.
|
||||
// unprovable file is named, never substituted. Following skills additionally refresh their current selection;
|
||||
// Codex receives that selection after resume readiness, without replaying other saved startup content.
|
||||
// The rehydration's decisions are carried into the fresh relaunch's replay below, so no later writer undoes them.
|
||||
let seatRehydration: SeatSkillRehydration | undefined;
|
||||
let restoredSkillCatalog: string | undefined;
|
||||
if (node.runtime === "claude-code" || node.runtime === "codex") {
|
||||
const seatSkills = seatSkillPaths({ rigId, logicalId: node.logicalId, runtime: node.runtime });
|
||||
if (hasSeatSkillRecords(seatSkills)) {
|
||||
let followsSources = hasFollowingSeatSkills(seatSkills);
|
||||
if (node.runtime === "claude-code") warnings?.push(...ensureSeatSkillPlugin(seatSkills).map((w) => `Restore: ${w}`));
|
||||
// Skills whose resource says `on_restore: follow_source` take today's bytes and today's selection, before the
|
||||
// saved ones are put back; an edit is still kept. When today's selection can't be read, the saved one stands.
|
||||
if (this.seatSkillSelection) {
|
||||
const today = await this.seatSkillSelection(rigId, nodeId).catch((error: unknown) => ({ ok: false as const, reason: (error as Error).message }));
|
||||
if (today.ok) {
|
||||
followsSources ||= today.selection.following.length > 0;
|
||||
const followed = followSeatSkills(seatSkills, today.selection);
|
||||
const changes = [["refreshed", followed.refreshed], ["added", followed.added], ["removed", followed.removed]] as const;
|
||||
const summary = changes.filter(([, ids]) => ids.length > 0).map(([what, ids]) => `${what} ${ids.join(", ")}`);
|
||||
if (summary.length > 0) warnings?.push(`Restore: ${node.logicalId} follows its skills' sources (on_restore: follow_source): ${summary.join("; ")}`);
|
||||
warnings?.push(...followed.warnings);
|
||||
} else if (hasFollowingSeatSkills(seatSkills)) {
|
||||
warnings?.push(`Restore: ${node.logicalId} keeps its saved skills: today's selection can't be read (${today.reason})`);
|
||||
}
|
||||
}
|
||||
const rehydrated = rehydrateSeatSkills(seatSkills);
|
||||
seatRehydration = rehydrated;
|
||||
if (rehydrated.restored.length > 0) warnings?.push(`Restore: put back ${rehydrated.restored.length} skill file(s) for ${node.logicalId} from their recorded sources`);
|
||||
@@ -1346,7 +1368,8 @@ export class RestoreOrchestrator {
|
||||
if (node.runtime === "codex") {
|
||||
try {
|
||||
const catalog = writeSeatSkillCatalog(seatSkills);
|
||||
if (catalog.kept) warnings?.push(`Restore: ${seatCatalogKeptNote(catalog)}`);
|
||||
if (catalog.kept) warnings?.push(`Restore: ${seatCatalogKeptNote(catalog)}; the updated catalog was not sent`);
|
||||
else if (followsSources) restoredSkillCatalog = catalog.path;
|
||||
} catch (error) {
|
||||
warnings?.push(`Restore: seat_catalog_not_written: ${seatSkills.catalogPath}: ${(error as Error).message}`);
|
||||
}
|
||||
@@ -1386,7 +1409,7 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
|
||||
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult,
|
||||
{ ...opts, ...(claudeExecutable ? { claudeExecutable } : {}) }, warnings, priorState, seatRehydration, resumeSession, activityHookGaps);
|
||||
{ ...opts, ...(claudeExecutable ? { claudeExecutable } : {}) }, warnings, priorState, seatRehydration, resumeSession, activityHookGaps, restoredSkillCatalog);
|
||||
const checked = { ...this.checkRestoredGuidance(restored, entry.node, rigId, data, warnings),
|
||||
...(activityHookGaps.length > 0 ? { restoreGaps: activityHookGaps } : {}),
|
||||
...(keptPane ? { launchStarted: true as const } : {}) };
|
||||
@@ -1646,6 +1669,7 @@ export class RestoreOrchestrator {
|
||||
/** Collected out-of-band (this method has many return points): activity-hook
|
||||
* restore gaps the caller attaches to the node result as `restoreGaps`. */
|
||||
activityHookGaps?: string[],
|
||||
restoredSkillCatalog?: string,
|
||||
): Promise<RestoreNodeResult> {
|
||||
const node = entry.node;
|
||||
// OPR.0.7.0.7: what this restore launches with when the seat has a saved effort choice is decided by the one
|
||||
@@ -2043,6 +2067,13 @@ export class RestoreOrchestrator {
|
||||
return true;
|
||||
});
|
||||
|
||||
// A following Codex seat gets its current list after native resume is ready, through the ordinary
|
||||
// startup delivery path. The saved startup context stays intact; no other startup material is replayed.
|
||||
if (resumeRequested && restoredSkillCatalog) filteredFiles.push({
|
||||
path: "openrig-seat-skills.md", absolutePath: restoredSkillCatalog,
|
||||
ownerRoot: dirname(restoredSkillCatalog), deliveryHint: "send_text", required: true, appliesOn: ["fresh_start", "restore"],
|
||||
});
|
||||
|
||||
// Check if any required files were dropped
|
||||
const missingRequired = sourceFiles.filter((f) => f.required && !existsFn(f.absolutePath));
|
||||
if (missingRequired.length > 0) {
|
||||
|
||||
@@ -327,7 +327,7 @@ import { planProjection, claudeConflictTargetPath, projectionConflictWarnings, f
|
||||
import { ProjectionManifestStore } from "./projection-manifest-store.js";
|
||||
import { StartupOrchestrator } from "./startup-orchestrator.js";
|
||||
import { PodRepository } from "./pod-repository.js";
|
||||
import type { RigSpec as PodRigSpec, RigSpecPod, RigSpecPodMember, StartupAction, StartupFile } from "./types.js";
|
||||
import type { RigSpec as PodRigSpec, RigSpecPod, RigSpecPodMember, SkillResource, StartupAction, StartupFile } from "./types.js";
|
||||
import type { RuntimeAdapter, NodeBinding, ResolvedStartupFile } from "./runtime-adapter.js";
|
||||
import { seatSkillsPointerStartupFile } from "./seat-skills-pointer.js";
|
||||
import { resolveConcreteHint } from "./runtime-adapter.js";
|
||||
@@ -338,7 +338,7 @@ import type {
|
||||
ContinuityPolicyMaterializer,
|
||||
} from "./continuity-policy-materializer.js";
|
||||
import { isKeptPluginSkill, type ReconcileSkillLoadoutResult, type SkillLoadout, type SkillLoadoutLocation, type SkillRuntime } from "./skill-catalog.js";
|
||||
import { claudeSeatSkillsNote, ensureSeatSkillPlugin, seatCatalogKeptNote, migrateLegacyProjectSkills, refusedSeatSkillsRootForNode, seatSkillLocation, seatSkillPaths, seatSkillPathsForNode, seatSkillPluginDirForNode, writeSeatSkillCatalog, type SeatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { claudeSeatSkillsNote, ensureSeatSkillPlugin, seatCatalogKeptNote, migrateLegacyProjectSkills, refusedSeatSkillsRootForNode, seatSkillLocation, seatSkillPaths, seatSkillPathsForNode, seatSkillPluginDirForNode, writeSeatSkillCatalog, type SeatSkillPaths, type SeatSkillSelection } from "./seat-skill-storage.js";
|
||||
import { carrySeatLaunchAdditions, prepareSeatLaunchAdditions, type SeatLaunchAdditionsCarry } from "./seat-launch-additions.js";
|
||||
import type { SystemWorldResolution } from "./system-world.js";
|
||||
import type { SeatEnv } from "./seat-env.js";
|
||||
@@ -2187,25 +2187,7 @@ export class PodRigInstantiator {
|
||||
};
|
||||
}
|
||||
|
||||
// The recipe supplies the authored source, while the retained node row is the
|
||||
// latest operator choice. Keep those choices authoritative across a restart.
|
||||
const member = {
|
||||
...context.member,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp ?? undefined : undefined,
|
||||
launchWrapper: isLaunchWrapperRuntime(node.runtime) ? (node.launchWrapper ?? []) : undefined,
|
||||
...(node.model !== null ? { model: node.model } : {}),
|
||||
...(node.effort !== null && node.effort !== undefined ? { effort: node.effort } : {}),
|
||||
...(node.codexConfigProfile !== null && node.codexConfigProfile !== undefined
|
||||
? { codexConfigProfile: node.codexConfigProfile }
|
||||
: {}),
|
||||
...(node.permissionPolicy !== null && node.permissionPolicy !== undefined
|
||||
? { permissionPolicy: node.permissionPolicy }
|
||||
: {}),
|
||||
...(node.env !== null && node.env !== undefined ? { env: node.env } : {}),
|
||||
...(node.cwd !== null ? { cwd: node.cwd } : {}),
|
||||
...(node.restorePolicy !== null ? { restorePolicy: node.restorePolicy } : {}),
|
||||
...(node.sessionSource !== null && node.sessionSource !== undefined ? { sessionSource: node.sessionSource } : {}),
|
||||
};
|
||||
const member = this.persistedMember(node, context.member);
|
||||
|
||||
// A fresh launch from the recipe runs no rig preflight, so the agent spec it resolves reports its own advisories.
|
||||
// The launch uses this same resolution rather than reading the spec again.
|
||||
@@ -2227,6 +2209,58 @@ export class PodRigInstantiator {
|
||||
return advisories.length ? { ...launched, warnings: [...advisories, ...(launched.warnings ?? [])] } : launched;
|
||||
}
|
||||
|
||||
/** The recipe supplies the authored source, while the retained node row is the
|
||||
* latest operator choice. Keep those choices authoritative across a restart. */
|
||||
private persistedMember(node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number], recipeMember: RigSpecPodMember): RigSpecPodMember {
|
||||
return {
|
||||
...recipeMember,
|
||||
launchWrapper: isLaunchWrapperRuntime(node.runtime) ? (node.launchWrapper ?? []) : undefined,
|
||||
claudeMcp: node.runtime === "claude-code" ? node.claudeMcp ?? undefined : undefined,
|
||||
...(node.model !== null ? { model: node.model } : {}),
|
||||
...(node.effort !== null && node.effort !== undefined ? { effort: node.effort } : {}),
|
||||
...(node.codexConfigProfile !== null && node.codexConfigProfile !== undefined
|
||||
? { codexConfigProfile: node.codexConfigProfile }
|
||||
: {}),
|
||||
...(node.permissionPolicy !== null && node.permissionPolicy !== undefined
|
||||
? { permissionPolicy: node.permissionPolicy }
|
||||
: {}),
|
||||
...(node.env !== null && node.env !== undefined ? { env: node.env } : {}),
|
||||
...(node.cwd !== null ? { cwd: node.cwd } : {}),
|
||||
...(node.restorePolicy !== null ? { restorePolicy: node.restorePolicy } : {}),
|
||||
...(node.sessionSource !== null && node.sessionSource !== undefined ? { sessionSource: node.sessionSource } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A seat's skill selection as its spec says today, for a restore that follows skills (`on_restore: follow_source`):
|
||||
* resolved from its launch recipe the way a fresh launch of it would be, with the agent spec read from disk now.
|
||||
* Every skill its profile selects, and those whose resource follows its source, with the source's full path. When it
|
||||
* can't be resolved, says why; the restore then keeps the saved selection.
|
||||
*/
|
||||
async seatSkillSelection(rigId: string, nodeId: string): Promise<{ ok: true; selection: SeatSkillSelection } | { ok: false; reason: string }> {
|
||||
const node = this.deps.rigRepo.getRig(rigId)?.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (!node) return { ok: false, reason: `node ${nodeId} not found` };
|
||||
const recipe = this.deps.rigRepo.getNodeLaunchRecipe(nodeId);
|
||||
if (!recipe) return { ok: false, reason: `${node.logicalId} has no launch recipe` };
|
||||
const context = this.findMemberContext(recipe.rigSpec, node.logicalId);
|
||||
if (!context) return { ok: false, reason: `${node.logicalId}'s launch recipe does not contain its member definition` };
|
||||
const member = this.persistedMember(node, context.member);
|
||||
const resolved = resolveAgentRef(member.agentRef, recipe.rigRoot, this.deps.fsOps);
|
||||
if (!resolved.ok) return { ok: false, reason: resolved.code === "validation_failed" ? resolved.errors.join("; ") : resolved.error };
|
||||
const config = await resolveNodeConfigAsync({ baseSpec: resolved.resolved, importedSpecs: resolved.imports, collisions: resolved.collisions,
|
||||
profileName: member.profile, specRoot: recipe.rigRoot, cwdOverride: node.cwd ?? undefined, member, pod: context.pod, rig: recipe.rigSpec,
|
||||
skillsRoot: this.resolveSkillsRoot(), ...this.systemWorldResolutionContext() }).catch(failedLaunchResolution);
|
||||
if (!config.ok) return { ok: false, reason: config.errors.join("; ") };
|
||||
const skills = config.config.selectedResources.skills;
|
||||
return { ok: true, selection: {
|
||||
selected: new Set(skills.map((skill) => skill.effectiveId)),
|
||||
following: skills.filter((skill) => (skill.resource as SkillResource).onRestore === "follow_source").map((skill) => ({
|
||||
id: skill.effectiveId,
|
||||
source: nodePath.resolve(skill.sourcePath, (skill.resource as SkillResource).path),
|
||||
})),
|
||||
} };
|
||||
}
|
||||
|
||||
/** Whether a persisted recipe contains enough validated source to attempt a fresh launch. */
|
||||
canLaunchPersistedFresh(rigId: string, nodeId: string): boolean {
|
||||
const rig = this.deps.rigRepo.getRig(rigId);
|
||||
|
||||
@@ -241,7 +241,7 @@ export function seatSkillPluginDirForNode(
|
||||
}
|
||||
|
||||
export interface SeatSkillRehydration {
|
||||
/** Files that were missing and were put back from their recorded source, with its mode. */
|
||||
/** Files that were missing and were put back from their recorded source, with their saved mode. */
|
||||
restored: string[];
|
||||
/** Files that differ from what OpenRig recorded writing: kept as they are. */
|
||||
kept: string[];
|
||||
@@ -253,6 +253,12 @@ export interface SeatSkillRehydration {
|
||||
|
||||
const sha256 = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Compare bytes and, when recorded, permissions before treating a seat file as unchanged. */
|
||||
function matchesRecordedFile(file: string, entry: { sha256: string; mode?: number }): boolean {
|
||||
return sha256(fs.readFileSync(file)) === entry.sha256
|
||||
&& (entry.mode === undefined || (fs.statSync(file).mode & 0o777) === entry.mode);
|
||||
}
|
||||
|
||||
/** Whether a recorded copy's source still has the bytes OpenRig wrote from it, so a restore can rebuild that copy. */
|
||||
const sourceHasRecordedBytes = (entry: { sha256: string; source: string }): boolean => {
|
||||
try { return sha256(fs.readFileSync(entry.source)) === entry.sha256; } catch { return false; }
|
||||
@@ -269,18 +275,18 @@ export function rehydrateSeatSkills(paths: SeatSkillPaths): SeatSkillRehydration
|
||||
for (const [file, entry] of recordedSeatSkillFiles(paths)) {
|
||||
if (!insideSeatStorage(paths.root, file)) { out.refused.push(file); continue; }
|
||||
if (lexists(file)) {
|
||||
try { if (sha256(fs.readFileSync(file)) !== entry.sha256) out.kept.push(file); } catch { out.kept.push(file); }
|
||||
try { if (!matchesRecordedFile(file, entry)) out.kept.push(file); } catch { out.kept.push(file); }
|
||||
continue;
|
||||
}
|
||||
let bytes: Buffer;
|
||||
let mode: number;
|
||||
try {
|
||||
bytes = fs.readFileSync(entry.source);
|
||||
mode = fs.statSync(entry.source).mode & 0o777;
|
||||
mode = entry.mode ?? (fs.statSync(entry.source).mode & 0o777);
|
||||
} catch { out.missing.push(file); continue; }
|
||||
if (sha256(bytes) !== entry.sha256) { out.missing.push(file); continue; }
|
||||
try {
|
||||
// The recorded source still has the recorded bytes, so its mode (a helper's executable bit) comes back too.
|
||||
// Older records lack a mode; new records restore exactly the permissions OpenRig wrote.
|
||||
writeInsideSeatStorage(paths.root, file, bytes, mode);
|
||||
out.restored.push(file);
|
||||
} catch {
|
||||
@@ -415,10 +421,18 @@ export function seatSkillReplay(input: {
|
||||
const blocked = (target: string) => notPutBack.some((f) => f === target || f.startsWith(target + nodePath.sep));
|
||||
const kept = seatSkillsToKeep(input.entries, paths);
|
||||
const skipEntryIds = new Set(kept.keep);
|
||||
const following = readProjections(paths.projectionsPath).following ?? {};
|
||||
const warnings = [...kept.warnings];
|
||||
for (const entry of input.entries) {
|
||||
if (entry.category !== "skill" || skipEntryIds.has(entry.effectiveId)) continue;
|
||||
const target = nodePath.resolve(paths.skillsRoot, entry.effectiveId);
|
||||
// Reconciliation already decided a following skill's contents, including its absence. An older startup plan
|
||||
// must not recreate it, even if today's spec becomes unavailable on a later restore.
|
||||
if (following[entry.effectiveId] && !lexists(target)) {
|
||||
skipEntryIds.add(entry.effectiveId);
|
||||
if (blocked(target)) warnings.push(`Restore: ${entry.effectiveId} not projected again: its following copy is missing and could not be restored`);
|
||||
continue;
|
||||
}
|
||||
if (blocked(target)) {
|
||||
skipEntryIds.add(entry.effectiveId);
|
||||
warnings.push(`Restore: ${target} not projected again: its recorded source is gone or has changed`);
|
||||
@@ -446,9 +460,12 @@ export function seatSkillReplay(input: {
|
||||
}
|
||||
|
||||
interface ProjectionRecord {
|
||||
files: Record<string, { sha256: string; source: string }>;
|
||||
files: Record<string, { sha256: string; source: string; mode?: number }>;
|
||||
/** What OpenRig last wrote as the seat's generated files (the Codex catalog), so an edit there is told apart. */
|
||||
generated?: Record<string, string>;
|
||||
/** Skills whose resource says `on_restore: follow_source`, by id (their folder under the skills root): the source
|
||||
* folder each was copied from. A restore brings these up to their source, and removes one no longer selected. */
|
||||
following?: Record<string, { source: string }>;
|
||||
}
|
||||
|
||||
const readProjections = (path: string): ProjectionRecord => {
|
||||
@@ -458,9 +475,11 @@ const readProjections = (path: string): ProjectionRecord => {
|
||||
} catch { return { files: {} }; }
|
||||
};
|
||||
|
||||
/** Persist ownership and following-policy metadata within the seat storage boundary. */
|
||||
function writeProjections(root: string, record: ProjectionRecord): void {
|
||||
writeInsideSeatStorage(root, nodePath.join(root, "skill-projections.json"),
|
||||
`${JSON.stringify({ schema: "openrig.seat-skill-projections/v1", files: record.files, ...(record.generated ? { generated: record.generated } : {}) }, null, 2)}\n`);
|
||||
`${JSON.stringify({ schema: "openrig.seat-skill-projections/v1", files: record.files, ...(record.generated ? { generated: record.generated } : {}),
|
||||
...(record.following && Object.keys(record.following).length > 0 ? { following: record.following } : {}) }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/** Record a skill file the Claude adapter wrote (or found already identical) in a seat's storage, so cleanup can tell
|
||||
@@ -469,19 +488,234 @@ export function recordSeatSkillWrite(skillsRoot: string, dest: string, source: s
|
||||
// `<root>/plugin/skills` (Claude) or `<root>/skills` (Codex): the record sits at the seat's root.
|
||||
const root = seatRootOfSkills(skillsRoot);
|
||||
const record = readProjections(nodePath.join(root, "skill-projections.json"));
|
||||
record.files[nodePath.resolve(dest)] = { sha256: sha256(Buffer.from(bytes)), source: nodePath.resolve(source) };
|
||||
// Mock filesystem adapters may not have a physical destination; older records also lack a mode.
|
||||
let mode: number | undefined;
|
||||
try { mode = fs.statSync(dest).mode & 0o777; } catch {}
|
||||
record.files[nodePath.resolve(dest)] = { sha256: sha256(Buffer.from(bytes)), source: nodePath.resolve(source), ...(mode === undefined ? {} : { mode }) };
|
||||
writeProjections(root, record);
|
||||
}
|
||||
|
||||
/** OPR.0.7.0.9: the seat's launch additions' locations, beside its skills: `additions/` (the prompt file and plugin
|
||||
* folders) and, for Claude, its generated plugin's `agents/`. The same record, restore and cleanup route covers them. */
|
||||
export function seatAdditionLocations(paths: SeatSkillPaths): string[] {
|
||||
export function seatAdditionLocations(paths: Pick<SeatSkillPaths, "root" | "skillsRoot" | "pluginDir">): string[] {
|
||||
return [nodePath.join(paths.root, "additions"), ...(paths.skillsRoot === nodePath.join(paths.pluginDir, "skills") ? [nodePath.join(paths.pluginDir, "agents")] : [])];
|
||||
}
|
||||
|
||||
/** Record that a skill copied into a seat's storage follows its source (`on_restore: follow_source`), or no longer does
|
||||
* (`source` undefined). `skillsRoot` is the binding's `seatSkillsRoot`. */
|
||||
export function recordSeatSkillFollowing(skillsRoot: string, id: string, source: string | undefined): void {
|
||||
const root = seatRootOfSkills(skillsRoot);
|
||||
const record = readProjections(nodePath.join(root, "skill-projections.json"));
|
||||
const following = { ...(record.following ?? {}) };
|
||||
if (source === undefined) {
|
||||
if (!(id in following)) return;
|
||||
delete following[id];
|
||||
} else {
|
||||
following[id] = { source: nodePath.resolve(source) };
|
||||
}
|
||||
writeProjections(root, { ...record, following });
|
||||
}
|
||||
|
||||
/** Best-effort policy bookkeeping for a no-op skill; never make an unchanged copy fail launch. */
|
||||
export function recordUnchangedSeatSkillFollowing(skillsRoot: string, id: string, source: string | undefined): void {
|
||||
try {
|
||||
if (source === undefined) {
|
||||
recordSeatSkillFollowing(skillsRoot, id, undefined);
|
||||
return;
|
||||
}
|
||||
const root = seatRootOfSkills(skillsRoot);
|
||||
const dir = nodePath.resolve(skillsRoot, id);
|
||||
if (!insideSeatStorageFolder(root, dir)) return;
|
||||
const files = filesBelow(dir);
|
||||
if (!files.includes("SKILL.md")) return;
|
||||
const owned = recordedSeatSkillFiles({ root, skillsRoot, pluginDir: nodePath.join(root, "plugin"),
|
||||
manifestPath: nodePath.join(root, "skill-loadout.json"), projectionsPath: nodePath.join(root, "skill-projections.json") });
|
||||
for (const rel of files) {
|
||||
const file = nodePath.join(dir, rel);
|
||||
const entry = owned.get(file);
|
||||
if (!entry || !insideSeatStorage(root, file) || !matchesRecordedFile(file, entry)) return;
|
||||
}
|
||||
recordSeatSkillFollowing(skillsRoot, id, source);
|
||||
} catch {
|
||||
// An unreadable copy or unwritable record must leave the existing skill usable.
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's skill selection for a seat, resolved from its spec as it is now. */
|
||||
export interface SeatSkillSelection {
|
||||
/** Every skill id the seat's profile selects today. */
|
||||
selected: ReadonlySet<string>;
|
||||
/** The selected skills whose resource says `on_restore: follow_source`, with their source folders. */
|
||||
following: ReadonlyArray<{ id: string; source: string }>;
|
||||
}
|
||||
|
||||
export interface SeatSkillFollow {
|
||||
/** Following skills whose copy now has its source's bytes, which differed from the saved copy. */
|
||||
refreshed: string[];
|
||||
/** Following skills newly selected, copied in. */
|
||||
added: string[];
|
||||
/** Following skills no longer selected, removed while still as OpenRig wrote them. */
|
||||
removed: string[];
|
||||
/** Copies kept as they are (an edit, an unprovable copy, a missing source), each named. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Every file below `dir` (anything not a folder, links included), relative to it; empty when it isn't there. */
|
||||
function filesBelow(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
/** Collect relative file paths without descending through directory symlinks. */
|
||||
const walk = (current: string) => {
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = nodePath.join(current, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else out.push(nodePath.relative(dir, full));
|
||||
}
|
||||
};
|
||||
if (lexists(dir)) walk(dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** Remove the empty folders below `dir`, and `dir` itself if it ends up empty, inside the seat's storage only. */
|
||||
function removeEmptyFolders(root: string, dir: string): void {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) if (entry.isDirectory()) removeEmptyFolders(root, nodePath.join(dir, entry.name));
|
||||
try { if (fs.readdirSync(dir).length === 0 && insideSeatStorageFolder(root, dir)) fs.rmdirSync(dir); } catch {}
|
||||
}
|
||||
|
||||
/** Transfer overlapping loadout ownership before following can replace or remove those files.
|
||||
* Persist the complete projection record first, so an interrupted transfer never loses ownership. */
|
||||
function adoptFollowingLoadoutFiles(paths: SeatSkillPaths, ids: string[], record: ProjectionRecord): ProjectionRecord {
|
||||
let manifest: Record<string, unknown> & { skills: Array<Record<string, unknown>> };
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(paths.manifestPath, "utf8"));
|
||||
if (!manifest || !Array.isArray(manifest.skills)) return record;
|
||||
} catch { return record; }
|
||||
const targets = new Set(ids.map((id) => nodePath.resolve(paths.skillsRoot, id)));
|
||||
const adopted = manifest.skills.filter((skill) => typeof skill?.target === "string" && targets.has(nodePath.resolve(skill.target)));
|
||||
if (adopted.length === 0) return record;
|
||||
const roots = adopted.map((skill) => nodePath.resolve(skill.target as string) + nodePath.sep);
|
||||
const files = { ...record.files };
|
||||
for (const [file, entry] of recordedSeatSkillFiles(paths)) {
|
||||
if (roots.some((root) => file.startsWith(root))) files[file] = entry;
|
||||
}
|
||||
const transferred = { ...record, files };
|
||||
writeProjections(paths.root, transferred);
|
||||
writeInsideSeatStorage(paths.root, paths.manifestPath,
|
||||
`${JSON.stringify({ ...manifest, skills: manifest.skills.filter((skill) => !adopted.includes(skill)) }, null, 2)}\n`);
|
||||
return transferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a restore that keeps a seat's conversation, before its saved skills are put back: bring each following skill
|
||||
* (`on_restore: follow_source`) up to today's selection. A selected one is copied from its source as it is now; one newly
|
||||
* selected is copied in; one recorded as following and no longer selected is removed. Each only while the seat's copy
|
||||
* is still what OpenRig wrote (or was removed by `rig down` for being so): an edited, unrecorded or linked copy is kept
|
||||
* and named. A source that can't be read leaves the saved copy to the usual rehydration. A skill still selected but no
|
||||
* longer following just stops following; its copy stays. Skills that keep their saved copy are not touched here.
|
||||
*/
|
||||
export function followSeatSkills(paths: SeatSkillPaths, today: SeatSkillSelection): SeatSkillFollow {
|
||||
const out: SeatSkillFollow = { refreshed: [], added: [], removed: [], warnings: [] };
|
||||
let record = readProjections(paths.projectionsPath);
|
||||
if (today.following.length === 0 && Object.keys(record.following ?? {}).length === 0) return out; // nothing follows
|
||||
record = adoptFollowingLoadoutFiles(paths, [...Object.keys(record.following ?? {}), ...today.following.map(({ id }) => id)], record);
|
||||
const files = { ...record.files };
|
||||
const following = { ...(record.following ?? {}) };
|
||||
const folderOf = (id: string) => nodePath.resolve(paths.skillsRoot, id);
|
||||
const recordedBelow = (dir: string) => Object.keys(files).filter((file) => file.startsWith(dir + nodePath.sep));
|
||||
/** Why the copy cannot be replaced, or undefined when every present file is unchanged and owned. */
|
||||
const notOurs = (dir: string): string | undefined => {
|
||||
if (!insideSeatStorageFolder(paths.root, dir)) return "it is not inside this seat's storage (a link on the way)";
|
||||
for (const file of recordedBelow(dir)) {
|
||||
if (!lexists(file)) continue;
|
||||
if (!insideSeatStorage(paths.root, file)) return `${file} is not inside this seat's storage (a link on the way)`;
|
||||
try { if (!matchesRecordedFile(file, files[file]!)) return `${file} was edited`; } catch { return `${file} can't be read`; }
|
||||
}
|
||||
try {
|
||||
for (const rel of filesBelow(dir)) if (!files[nodePath.join(dir, rel)]) return `${nodePath.join(dir, rel)} is not a file OpenRig wrote`;
|
||||
} catch { return `${dir} can't be read`; }
|
||||
return undefined;
|
||||
};
|
||||
const kept = (dir: string, why: string) =>
|
||||
out.warnings.push(`Restore: kept ${dir}: ${why}; it follows its source (on_restore: follow_source), but an edit is never overwritten`);
|
||||
/** Remove the already-verified files below `dir` and their ownership records. */
|
||||
const clear = (dir: string) => {
|
||||
for (const file of recordedBelow(dir)) {
|
||||
if (lexists(file)) removeInsideSeatStorage(paths.root, file);
|
||||
delete files[file];
|
||||
}
|
||||
removeEmptyFolders(paths.root, dir);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const { id, source } of today.following) {
|
||||
const dir = folderOf(id);
|
||||
const sourceDir = nodePath.resolve(source);
|
||||
let tree: Array<{ rel: string; bytes: Buffer; mode: number }>;
|
||||
try {
|
||||
const sourceFiles = filesBelow(sourceDir);
|
||||
if (!sourceFiles.includes("SKILL.md")) throw new Error("no SKILL.md");
|
||||
tree = sourceFiles.map((rel) => {
|
||||
const file = nodePath.join(sourceDir, rel);
|
||||
return { rel, bytes: fs.readFileSync(file), mode: fs.statSync(file).mode & 0o777 };
|
||||
});
|
||||
} catch {
|
||||
out.warnings.push(`Restore: ${id} follows its source, but ${sourceDir} can't be read as a skill; its saved copy is kept`);
|
||||
following[id] = { source: sourceDir };
|
||||
continue;
|
||||
}
|
||||
const why = notOurs(dir);
|
||||
if (why) { kept(dir, why); continue; }
|
||||
const before = recordedBelow(dir);
|
||||
following[id] = { source: sourceDir };
|
||||
const same = before.length === tree.length
|
||||
&& tree.every(({ rel, bytes, mode }) => {
|
||||
const file = nodePath.join(dir, rel);
|
||||
return files[file]?.sha256 === sha256(bytes)
|
||||
&& files[file]?.source === nodePath.join(sourceDir, rel)
|
||||
&& (files[file]?.mode === undefined || files[file]?.mode === mode)
|
||||
&& (!lexists(file) || (fs.statSync(file).mode & 0o777) === mode);
|
||||
});
|
||||
if (same) continue; // the saved copy is today's; the usual rehydration puts back whatever rig down removed
|
||||
clear(dir);
|
||||
for (const { rel, bytes, mode } of tree) {
|
||||
const file = nodePath.join(dir, rel);
|
||||
writeInsideSeatStorage(paths.root, file, bytes, mode);
|
||||
files[file] = { sha256: sha256(bytes), source: nodePath.join(sourceDir, rel), mode };
|
||||
}
|
||||
(before.length > 0 ? out.refreshed : out.added).push(id);
|
||||
}
|
||||
|
||||
const followingToday = new Set(today.following.map((skill) => skill.id));
|
||||
for (const id of Object.keys(following).sort()) {
|
||||
if (followingToday.has(id)) continue;
|
||||
if (today.selected.has(id)) { delete following[id]; continue; } // selected, now keeping its saved copy
|
||||
const dir = folderOf(id);
|
||||
const why = notOurs(dir);
|
||||
if (why) { kept(dir, `${why}, and it is no longer selected`); continue; }
|
||||
// Keep the following policy after removal: an older saved startup plan must not replay this skill later.
|
||||
const hadCopy = recordedBelow(dir).length > 0 || lexists(dir);
|
||||
clear(dir);
|
||||
if (hadCopy) out.removed.push(id);
|
||||
}
|
||||
|
||||
} finally {
|
||||
// A partial refresh must remain owned so a later restore can finish it.
|
||||
try { writeProjections(paths.root, { ...record, files, following }); }
|
||||
catch (error) { out.warnings.push(`Restore: could not record skill ownership in ${paths.root}: ${(error as Error).message}`); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether any of this seat's skills is recorded as following its source. */
|
||||
export function hasFollowingSeatSkills(paths: SeatSkillPaths): boolean {
|
||||
return Object.keys(readProjections(paths.projectionsPath).following ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/** Every file OpenRig recorded writing into this seat's skills or launch additions: its recorded hash and source. */
|
||||
function recordedSeatSkillFiles(paths: SeatSkillPaths): Map<string, { sha256: string; source: string }> {
|
||||
const out = new Map<string, { sha256: string; source: string }>();
|
||||
function recordedSeatSkillFiles(paths: Pick<SeatSkillPaths, "root" | "skillsRoot" | "pluginDir" | "manifestPath" | "projectionsPath">): Map<string, { sha256: string; source: string; mode?: number }> {
|
||||
const out = new Map<string, { sha256: string; source: string; mode?: number }>();
|
||||
const roots = [paths.skillsRoot, ...seatAdditionLocations(paths)].map((dir) => nodePath.resolve(dir) + nodePath.sep);
|
||||
const inside = (file: string) => roots.some((root) => file.startsWith(root));
|
||||
try {
|
||||
@@ -517,7 +751,7 @@ export interface SeatSkillCleanup {
|
||||
|
||||
/**
|
||||
* Remove what OpenRig wrote into a seat's skills once the seat's consumers have stopped. A recorded file goes only
|
||||
* while its bytes still match the record and its source still has those bytes, so a later restore can rebuild it.
|
||||
* while its bytes and recorded mode still match and its source still has those bytes, so a later restore can rebuild it.
|
||||
* The generated plugin's manifest goes too, so nothing points at the storage. Edited and unrecorded files are kept
|
||||
* and reported. So is a recorded file whose source moved on or can't be read: it is the only copy of the saved bytes,
|
||||
* and a restore can't put it back from the source. The records themselves stay, unless `dropRecords` (the rig is
|
||||
@@ -532,7 +766,7 @@ export function cleanSeatSkills(paths: SeatSkillPaths, opts: { dropRecords?: boo
|
||||
if (!lexists(file)) continue;
|
||||
if (!insideSeatStorage(paths.root, file)) { out.refused.push(file); continue; }
|
||||
try {
|
||||
if (sha256(fs.readFileSync(file)) !== entry.sha256) { out.kept.push(file); continue; }
|
||||
if (!matchesRecordedFile(file, entry)) { out.kept.push(file); continue; }
|
||||
// A copy its source no longer has is the saved selection itself: keep it while a restore can still need it.
|
||||
if (!opts.dropRecords && !sourceHasRecordedBytes(entry)) { out.kept.push(file); continue; }
|
||||
if (removeInsideSeatStorage(paths.root, file)) { out.removed.push(file); removedFiles.push(file); }
|
||||
@@ -669,7 +903,7 @@ export function writeSeatSkillCatalog(paths: SeatSkillPaths): SeatSkillCatalog {
|
||||
"OpenRig: this seat's skills. This list replaces any earlier OpenRig skill list in this conversation.",
|
||||
"",
|
||||
lines.length > 0
|
||||
? "Each skill says when to use it. When that moment comes, read its SKILL.md, then follow it."
|
||||
? "Each skill says when to use it. When that moment comes, read its current SKILL.md, then follow it; earlier copies read in this conversation may be out of date."
|
||||
: "No OpenRig skills are kept for this seat.",
|
||||
"",
|
||||
...lines,
|
||||
|
||||
@@ -584,7 +584,7 @@ export class StartupOrchestrator {
|
||||
"INSERT OR REPLACE INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
input.nodeId,
|
||||
JSON.stringify(input.plan.entries.map((e) => ({ category: e.category, effectiveId: e.effectiveId, sourceSpec: e.sourceSpec, sourcePath: e.sourcePath, resourcePath: e.resourcePath, absolutePath: e.absolutePath, resourceType: e.resourceType, mergeStrategy: e.mergeStrategy, target: e.target }))),
|
||||
JSON.stringify(input.plan.entries.map((e) => ({ category: e.category, effectiveId: e.effectiveId, sourceSpec: e.sourceSpec, sourcePath: e.sourcePath, resourcePath: e.resourcePath, absolutePath: e.absolutePath, resourceType: e.resourceType, mergeStrategy: e.mergeStrategy, target: e.target, ...(e.onRestore ? { onRestore: e.onRestore } : {}) }))),
|
||||
JSON.stringify(input.resolvedStartupFiles),
|
||||
JSON.stringify(input.startupActions),
|
||||
input.adapter.runtime,
|
||||
|
||||
@@ -387,6 +387,8 @@ export interface PersistedProjectionEntry {
|
||||
resourceType?: string;
|
||||
mergeStrategy?: string;
|
||||
target?: string;
|
||||
/** A skill whose resource says `on_restore: follow_source`, so a replay keeps it recorded as following. */
|
||||
onRestore?: SkillOnRestore;
|
||||
}
|
||||
|
||||
export interface NodeStartupSnapshot {
|
||||
@@ -1050,7 +1052,10 @@ export interface LifecycleDefaults {
|
||||
restorePolicy?: "resume_if_possible" | "relaunch_fresh" | "checkpoint_only";
|
||||
}
|
||||
|
||||
export interface SkillResource { id: string; path: string; }
|
||||
/** What a restore that keeps a seat's conversation does with this skill's copy in the seat's storage: keep the saved
|
||||
* copy (the default), or follow the source, taking today's bytes and today's selection. */
|
||||
export type SkillOnRestore = "keep_saved" | "follow_source";
|
||||
export interface SkillResource { id: string; path: string; onRestore?: SkillOnRestore; }
|
||||
export interface GuidanceResource { id: string; path: string; target: string; merge: "managed_block" | "append"; }
|
||||
export interface SubagentResource { id: string; path: string; }
|
||||
export interface RuntimeResource { id: string; path: string; runtime: string; type: string; }
|
||||
|
||||
@@ -52,7 +52,7 @@ import { createCmuxCliTransport } from "./adapters/cmux-transport.js";
|
||||
import { SnapshotRepository } from "./domain/snapshot-repository.js";
|
||||
import { CheckpointStore } from "./domain/checkpoint-store.js";
|
||||
import { SnapshotCapture } from "./domain/snapshot-capture.js";
|
||||
import { RestoreOrchestrator } from "./domain/restore-orchestrator.js";
|
||||
import { RestoreOrchestrator, type RestoreOrchestratorDeps } from "./domain/restore-orchestrator.js";
|
||||
import { createDesiredClaudeActivityHooksReader } from "./domain/desired-claude-activity-hooks.js";
|
||||
import { ClaudeManagedLaunch } from "./domain/claude-managed-launch.js";
|
||||
import { ClaudeResumeAdapter } from "./adapters/claude-resume.js";
|
||||
@@ -804,6 +804,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
warnings?: string[];
|
||||
}>) | undefined;
|
||||
let persistedFreshPodLaunchAvailable: ((rigId: string, nodeId: string) => boolean) | undefined;
|
||||
let persistedSeatSkillSelection: RestoreOrchestratorDeps["seatSkillSelection"];
|
||||
|
||||
const restoreOrchestrator = new RestoreOrchestrator({
|
||||
db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
|
||||
@@ -816,6 +817,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
? persistedFreshPodLaunch(rigId, nodeId)
|
||||
: { status: "failed", error: "Pod instantiator is not ready for a fresh launch." },
|
||||
freshLaunchAvailable: (rigId, nodeId) => persistedFreshPodLaunchAvailable?.(rigId, nodeId) ?? false,
|
||||
seatSkillSelection: async (rigId, nodeId) => persistedSeatSkillSelection
|
||||
? persistedSeatSkillSelection(rigId, nodeId)
|
||||
: { ok: false, reason: "the pod instantiator is not ready" },
|
||||
});
|
||||
|
||||
// Connect to cmux at startup — degrades gracefully if absent
|
||||
@@ -1219,6 +1223,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
});
|
||||
persistedFreshPodLaunch = (rigId, nodeId) => podInstantiator.launchPersistedFresh(rigId, nodeId);
|
||||
persistedFreshPodLaunchAvailable = (rigId, nodeId) => podInstantiator.canLaunchPersistedFresh(rigId, nodeId);
|
||||
persistedSeatSkillSelection = (rigId, nodeId) => podInstantiator.seatSkillSelection(rigId, nodeId);
|
||||
|
||||
const podBundleSourceResolver = new PodBundleSourceResolver();
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseAgentSpec, validateAgentSpec, normalizeAgentSpec } from "../src/domain/agent-manifest.js";
|
||||
|
||||
const spec = (onRestore?: string) => parseAgentSpec(`
|
||||
name: team
|
||||
version: "1.0"
|
||||
resources:
|
||||
skills:
|
||||
- id: house-style
|
||||
path: skills/house-style
|
||||
${onRestore === undefined ? "" : ` on_restore: ${onRestore}\n`}`);
|
||||
|
||||
describe("a skill resource's on_restore says whether a restored seat keeps its saved copy or follows the source", () => {
|
||||
it("keeps the saved copy unless the skill says otherwise", () => {
|
||||
const raw = spec();
|
||||
expect(validateAgentSpec(raw).errors).toEqual([]);
|
||||
expect(normalizeAgentSpec(raw).resources.skills).toEqual([{ id: "house-style", path: "skills/house-style", onRestore: "keep_saved" }]);
|
||||
});
|
||||
|
||||
it("accepts follow_source and keep_saved", () => {
|
||||
for (const value of ["follow_source", "keep_saved"]) {
|
||||
const raw = spec(value);
|
||||
expect(validateAgentSpec(raw).errors).toEqual([]);
|
||||
expect(normalizeAgentSpec(raw).resources.skills[0]!.onRestore).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it("advises on unknown values, even on an unselected resource, and keeps the saved copy", () => {
|
||||
const result = validateAgentSpec(spec("refresh"));
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(normalizeAgentSpec(spec("refresh")).resources.skills[0]!.onRestore).toBe("keep_saved");
|
||||
expect(result.advisories).toContain(`resources.skills[0].on_restore: unknown value "refresh"; expected "keep_saved" | "follow_source"; using "keep_saved"`);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
|
||||
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { RestoreOrchestrator, type RestoreOrchestratorDeps } from "../src/domain/restore-orchestrator.js";
|
||||
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
|
||||
import { SeatHandoverService } from "../src/domain/seat-handover-service.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
@@ -34,7 +34,7 @@ import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV } from "../src/domain/claude-core-p
|
||||
import {
|
||||
persistedPluginPaths, seatCorePluginDir, vendoredCorePluginDir, withCorePlugin,
|
||||
} from "../src/domain/claude-core-plugin-env.js";
|
||||
import { ensureSeatSkillPlugin, recordSeatSkillWrite, seatSkillPaths } from "../src/domain/seat-skill-storage.js";
|
||||
import { ensureSeatSkillPlugin, recordSeatSkillFollowing, recordSeatSkillWrite, seatSkillPaths } from "../src/domain/seat-skill-storage.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { RigSpec } from "../src/domain/types.js";
|
||||
@@ -375,7 +375,8 @@ describe("a Claude seat's launch env, on the launch paths that don't re-resolve
|
||||
expect(created[3]!.env![PLUGIN_ENV]).toBe(OWN_LIST);
|
||||
});
|
||||
|
||||
function restoreHarness(entries: unknown[], memberEnv?: Record<string, string>, opts: { fresh?: boolean; files?: unknown[]; realFs?: boolean } = {}) {
|
||||
function restoreHarness(entries: unknown[], memberEnv?: Record<string, string>, opts: { fresh?: boolean; files?: unknown[]; realFs?: boolean;
|
||||
seatSkillSelection?: RestoreOrchestratorDeps["seatSkillSelection"] } = {}) {
|
||||
const { pluginsDir } = vendoredPlugins(root);
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const rigRepo = new RigRepository(db);
|
||||
@@ -429,7 +430,7 @@ describe("a Claude seat's launch env, on the launch paths that don't re-resolve
|
||||
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux, sessionEnv: { OPENRIG_HOME: "/state" }, pluginsDir });
|
||||
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
|
||||
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
|
||||
codexResume: new CodexResumeAdapter(tmux), listProcesses });
|
||||
codexResume: new CodexResumeAdapter(tmux), listProcesses, ...(opts.seatSkillSelection ? { seatSkillSelection: opts.seatSkillSelection } : {}) });
|
||||
const project = adapter.project.bind(adapter);
|
||||
adapter.project = async (plan, b) => { projected.push(...plan.entries.map((e) => e.effectiveId)); return project(plan, b); };
|
||||
return await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter }, ...(opts.fresh ? { freshLogicalIds: ["test.c"] } : {}),
|
||||
@@ -480,6 +481,61 @@ describe("a Claude seat's launch env, on the launch paths that don't re-resolve
|
||||
expect(JSON.stringify(result)).toContain("put back 1 skill file(s) for test.c");
|
||||
});
|
||||
|
||||
it("rig down then restore (native resume): a skill that follows its source comes back with today's bytes, before its session starts", async () => {
|
||||
const src = join(root, "src", "infra-review");
|
||||
let seen: string | undefined;
|
||||
const h = restoreHarness([], undefined, { seatSkillSelection: async () => ({ ok: true, selection: { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: src }] } }) });
|
||||
const seat = seatSkillPaths({ rigId: h.rigId, logicalId: "test.c", runtime: "claude-code" });
|
||||
ensureSeatSkillPlugin(seat);
|
||||
const file = join(seat.skillsRoot, "infra-review", "SKILL.md");
|
||||
for (const path of [join(src, "SKILL.md"), file]) { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, "v1\n"); }
|
||||
recordSeatSkillWrite(seat.skillsRoot, file, join(src, "SKILL.md"), "v1\n");
|
||||
recordSeatSkillFollowing(seat.skillsRoot, "infra-review", src);
|
||||
// axiom-maths/skills moved on while the seat was down.
|
||||
writeFileSync(join(src, "SKILL.md"), "v2\n");
|
||||
const created = h.created;
|
||||
const push = created.push.bind(created);
|
||||
created.push = (...envs) => { seen = existsSync(file) ? readFileSync(file, "utf8") : "missing"; return push(...envs); };
|
||||
const result = await h.run();
|
||||
expect(seen).toBe("v2\n");
|
||||
expect(JSON.stringify(result)).toContain("Restore: test.c follows its skills' sources (on_restore: follow_source): refreshed infra-review");
|
||||
expect(JSON.stringify(result)).not.toContain("not substituted");
|
||||
});
|
||||
|
||||
it("restore (native resume): when today's selection can't be read, the seat keeps its saved skills and the restore says why", async () => {
|
||||
const src = join(root, "src", "infra-review");
|
||||
const h = restoreHarness([], undefined, { seatSkillSelection: async () => ({ ok: false, reason: "agent.yaml not found" }) });
|
||||
const seat = seatSkillPaths({ rigId: h.rigId, logicalId: "test.c", runtime: "claude-code" });
|
||||
ensureSeatSkillPlugin(seat);
|
||||
const file = join(seat.skillsRoot, "infra-review", "SKILL.md");
|
||||
for (const path of [join(src, "SKILL.md"), file]) { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, "v1\n"); }
|
||||
recordSeatSkillWrite(seat.skillsRoot, file, join(src, "SKILL.md"), "v1\n");
|
||||
recordSeatSkillFollowing(seat.skillsRoot, "infra-review", src);
|
||||
const result = await h.run();
|
||||
expect(readFileSync(file, "utf8")).toBe("v1\n");
|
||||
expect(h.created).toHaveLength(1);
|
||||
expect(JSON.stringify(result)).toContain("Restore: test.c keeps its saved skills: today's selection can't be read (agent.yaml not found)");
|
||||
});
|
||||
|
||||
it("fresh restore does not replay a following skill that today's selection removed", async () => {
|
||||
const src = join(root, "src", "infra-review");
|
||||
mkdirSync(src, { recursive: true });
|
||||
writeFileSync(join(src, "SKILL.md"), "old skill\n");
|
||||
const h = restoreHarness([{ category: "skill", effectiveId: "infra-review", absolutePath: src, onRestore: "follow_source" }], undefined,
|
||||
{ fresh: true, realFs: true, seatSkillSelection: async () => ({ ok: true, selection: { selected: new Set(), following: [] } }) });
|
||||
const seat = seatSkillPaths({ rigId: h.rigId, logicalId: "test.c", runtime: "claude-code" });
|
||||
ensureSeatSkillPlugin(seat);
|
||||
const file = join(seat.skillsRoot, "infra-review", "SKILL.md");
|
||||
mkdirSync(join(file, ".."), { recursive: true });
|
||||
writeFileSync(file, "old skill\n");
|
||||
recordSeatSkillWrite(seat.skillsRoot, file, join(src, "SKILL.md"), "old skill\n");
|
||||
recordSeatSkillFollowing(seat.skillsRoot, "infra-review", src);
|
||||
const result = await h.run();
|
||||
expect(result).toMatchObject({ ok: true, result: { nodes: [{ status: "fresh-primed" }] } });
|
||||
expect(h.projected).not.toContain("infra-review");
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh restore, end to end: a startup-file skill the person edited is kept through the real startup install", async () => {
|
||||
const src = join(root, "src-fresh", "orient", "SKILL.md");
|
||||
mkdirSync(join(src, ".."), { recursive: true });
|
||||
|
||||
@@ -68,6 +68,17 @@ describe("Projection planner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks a skill whose resource follows its source, and only that one", () => {
|
||||
const follows = { effectiveId: "infra-review", sourceSpec: "base", sourcePath: "/agents/base",
|
||||
resource: { id: "infra-review", path: "skills/infra-review", onRestore: "follow_source" } as QualifiedResource["resource"] };
|
||||
const keeps = { effectiveId: "tdd", sourceSpec: "base", sourcePath: "/agents/base",
|
||||
resource: { id: "tdd", path: "skills/tdd", onRestore: "keep_saved" } as QualifiedResource["resource"] };
|
||||
const result = planProjection({ config: makeConfig({ selectedResources: { ...emptyResources(), skills: [follows, keeps, makeQR("plain", "skills/plain")] } }),
|
||||
collisions: [], fsOps: mockFs() });
|
||||
expect(result.ok && Object.fromEntries(result.plan.entries.map((e) => [e.effectiveId, e.onRestore])))
|
||||
.toEqual({ "infra-review": "follow_source", tdd: undefined, plain: undefined });
|
||||
});
|
||||
|
||||
// T2: non-matching runtime_resources excluded
|
||||
it("excludes non-matching runtime_resources", () => {
|
||||
const config = makeConfig({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { seatSkillPaths, recordSeatSkillWrite, recordSeatSkillFollowing, cleanSeatSkills } from "../src/domain/seat-skill-storage.js";
|
||||
import type { RestoreOrchestratorDeps } from "../src/domain/restore-orchestrator.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { TerminalAdapter } from "../src/adapters/terminal-adapter.js";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
@@ -122,6 +124,7 @@ describe("RestoreOrchestrator", () => {
|
||||
});
|
||||
|
||||
function createOrchestrator(opts?: {
|
||||
seatSkillSelection?: RestoreOrchestratorDeps["seatSkillSelection"];
|
||||
tmux?: TmuxAdapter;
|
||||
claude?: ClaudeResumeAdapter;
|
||||
codex?: CodexResumeAdapter;
|
||||
@@ -145,9 +148,82 @@ describe("RestoreOrchestrator", () => {
|
||||
ompResume: opts?.omp,
|
||||
listProcesses: opts?.listProcesses,
|
||||
freshLaunch: opts?.freshLaunch,
|
||||
seatSkillSelection: opts?.seatSkillSelection,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["follow", "saved", "removed", "edited-catalog", "unavailable-spec", "delivery-failure"] as const)(
|
||||
"Codex restore delivers the current catalog only for following seats: %s", async (mode) => {
|
||||
const rig = rigRepo.createRig("follow-codex");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-follow", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.owner", { runtime: "codex", podId: "pod-follow" });
|
||||
const token = "00000000-0000-4000-8000-000000000086";
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-owner@follow-codex");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateResumeToken(session.id, "codex_id", token);
|
||||
const savedFiles = JSON.stringify([{ path: "old-role.md", absolutePath: "/old-role.md", ownerRoot: "/",
|
||||
deliveryHint: "send_text", required: false, appliesOn: ["restore"] }]);
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, '[]', ?, '[]', 'codex')")
|
||||
.run(node.id, savedFiles);
|
||||
const paths = seatSkillPaths({ rigId: rig.id, logicalId: node.logicalId, runtime: "codex" });
|
||||
const source = path.join(paths.root, "source");
|
||||
const oldSource = path.join(paths.root, "old-source");
|
||||
const oldFile = path.join(paths.skillsRoot, "old", "SKILL.md");
|
||||
const oldText = "---\nname: old\ndescription: old trigger\n---\nold body";
|
||||
for (const dir of [source, oldSource, path.dirname(oldFile)]) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(oldSource, "SKILL.md"), oldText);
|
||||
fs.writeFileSync(oldFile, oldText);
|
||||
recordSeatSkillWrite(paths.skillsRoot, oldFile, path.join(oldSource, "SKILL.md"), oldText);
|
||||
if (mode !== "saved") recordSeatSkillFollowing(paths.skillsRoot, "old", oldSource);
|
||||
cleanSeatSkills(paths);
|
||||
fs.writeFileSync(path.join(source, "SKILL.md"), "---\nname: new-review\ndescription: review new changes\n---\nread the new instructions");
|
||||
if (mode === "edited-catalog") fs.writeFileSync(paths.catalogPath, "personal catalog edit");
|
||||
const snapshot = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
const delivered: string[] = [];
|
||||
const order: string[] = [];
|
||||
const adapter: RuntimeAdapter = {
|
||||
runtime: "codex", listInstalled: async () => [], project: async () => ({ projected: [], skipped: [], failed: [] }),
|
||||
launchHarness: vi.fn(async (_binding, opts) => {
|
||||
expect(opts.resumeToken).toBe(token); order.push("resume");
|
||||
return { ok: true, resumeToken: token, resumeType: "codex_id" };
|
||||
}),
|
||||
checkReady: async () => { order.push("ready"); return { ready: true }; },
|
||||
deliverStartup: async (files) => {
|
||||
for (const file of files) { delivered.push(fs.readFileSync(file.absolutePath, "utf8")); order.push("catalog"); }
|
||||
return { delivered: files.length, failed: mode === "delivery-failure" && files.length > 0 ? [{ path: "catalog", error: "send failed" }] : [] };
|
||||
},
|
||||
};
|
||||
const tmux = mockTmux();
|
||||
tmux.getPaneCommand = vi.fn(async () => "codex");
|
||||
const startedAt = "Sat Jan 1 12:00:00 2000";
|
||||
const processes = [
|
||||
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, executableName: "codex", command: `/opt/native/codex resume ${token}`, argv: ["/opt/native/codex", "resume", token], startedAt },
|
||||
];
|
||||
const result = await createOrchestrator({ tmux, listProcesses: async () => processes,
|
||||
seatSkillSelection: async () => mode === "unavailable-spec" ? { ok: false, reason: "spec unavailable" } : {
|
||||
ok: true, selection: mode === "saved" ? { selected: new Set(["old"]), following: [] }
|
||||
: { selected: new Set(mode === "removed" ? [] : ["new-review"]), following: mode === "removed" ? [] : [{ id: "new-review", source }] },
|
||||
},
|
||||
}).restore(snapshot.id, { adapters: { codex: adapter } });
|
||||
expect(result.ok).toBe(true);
|
||||
if (mode === "saved" || mode === "edited-catalog") expect(delivered).toEqual([]);
|
||||
else {
|
||||
expect(delivered, JSON.stringify({ result, order })).toHaveLength(1);
|
||||
expect(delivered[0]).toContain("replaces any earlier OpenRig skill list");
|
||||
expect(order.indexOf("catalog")).toBeGreaterThan(order.indexOf("ready"));
|
||||
if (mode === "removed") expect(delivered[0]).toContain("No OpenRig skills");
|
||||
else if (mode === "unavailable-spec") expect(delivered[0]).toContain("old trigger");
|
||||
else { expect(delivered[0]).toContain("new-review"); expect(delivered[0]).not.toContain("old trigger"); }
|
||||
}
|
||||
if (mode === "edited-catalog" && result.ok) expect(result.result.warnings.join("\n")).toContain("the updated catalog was not sent");
|
||||
if (mode === "delivery-failure" && result.ok) expect(result.result.nodes[0]?.status).not.toBe("resumed");
|
||||
expect(db.prepare("SELECT resolved_files_json FROM node_startup_context WHERE node_id = ?").get(node.id))
|
||||
.toEqual({ resolved_files_json: savedFiles });
|
||||
fs.rmSync(paths.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedRigAndSnapshot(opts?: {
|
||||
edges?: { sourceLogical: string; targetLogical: string; kind: string }[];
|
||||
nodes?: { logicalId: string; role: string; runtime: string; cwd?: string }[];
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
// A skill whose resource says `on_restore: follow_source` takes today's bytes and today's selection when a restore
|
||||
// brings its seat back with its conversation; a seat's edit is still never overwritten.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs, { chmodSync, statSync, symlinkSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { planProjection } from "../src/domain/projection-planner.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
cleanSeatSkills, ensureSeatSkillPlugin, followSeatSkills, recordSeatSkillFollowing, recordSeatSkillWrite, rehydrateSeatSkills, seatSkillPaths, seatSkillReplay,
|
||||
} from "../src/domain/seat-skill-storage.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
|
||||
describe("a restore brings a following skill's copy up to its source, and keeps a seat's edit", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "seat-skills-follow-")); });
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
/** Create isolated seat storage and helpers that model a recorded skill projection. */
|
||||
function seat(runtime = "claude-code") {
|
||||
const paths = seatSkillPaths({ rigId: "rig-1", logicalId: "dev.impl", runtime }, home);
|
||||
ensureSeatSkillPlugin(paths);
|
||||
const sourceOf = (id: string) => join(home, "skills", id);
|
||||
const put = (file: string, text: string) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); };
|
||||
/** A skill as a launch leaves it: source and seat copy alike, every file recorded, following its source or not. */
|
||||
const launched = (id: string, files: Record<string, string>, follow = true) => {
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
put(join(sourceOf(id), rel), text);
|
||||
put(join(paths.skillsRoot, id, rel), text);
|
||||
recordSeatSkillWrite(paths.skillsRoot, join(paths.skillsRoot, id, rel), join(sourceOf(id), rel), text);
|
||||
}
|
||||
if (follow) recordSeatSkillFollowing(paths.skillsRoot, id, sourceOf(id));
|
||||
};
|
||||
const copy = (id: string, rel: string) => readFileSync(join(paths.skillsRoot, id, rel), "utf8");
|
||||
return { paths, sourceOf, put, launched, copy };
|
||||
}
|
||||
|
||||
it("refreshes an unchanged copy from its changed source, and records the new bytes", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1", "scripts/check.sh": "check v1" });
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
put(join(sourceOf("infra-review"), "references/aws.md"), "new reference");
|
||||
rmSync(join(sourceOf("infra-review"), "scripts"), { recursive: true });
|
||||
|
||||
const out = followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
|
||||
expect(out).toEqual({ refreshed: ["infra-review"], added: [], removed: [], warnings: [] });
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v2");
|
||||
expect(copy("infra-review", "references/aws.md")).toBe("new reference");
|
||||
expect(existsSync(join(paths.skillsRoot, "infra-review", "scripts"))).toBe(false);
|
||||
// The record now holds today's bytes, so the next rig down removes the copy and the next restore puts it back.
|
||||
expect(cleanSeatSkills(paths).kept).toEqual([]);
|
||||
expect(rehydrateSeatSkills(paths).restored.sort()).toEqual([
|
||||
join(paths.skillsRoot, "infra-review", "SKILL.md"), join(paths.skillsRoot, "infra-review", "references/aws.md")]);
|
||||
});
|
||||
it("refreshes a copy rig down removed for being unchanged, so the restore doesn't name it missing", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
cleanSeatSkills(paths);
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
|
||||
expect(followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] }).refreshed)
|
||||
.toEqual(["infra-review"]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v2");
|
||||
expect(rehydrateSeatSkills(paths)).toEqual({ restored: [], kept: [], missing: [], refused: [] });
|
||||
});
|
||||
|
||||
it("refreshes a copy whose source changed before rig down, whether rig down removed it or kept it", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
cleanSeatSkills(paths);
|
||||
|
||||
expect(followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] }))
|
||||
.toEqual({ refreshed: ["infra-review"], added: [], removed: [], warnings: [] });
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v2");
|
||||
expect(rehydrateSeatSkills(paths)).toEqual({ restored: [], kept: [], missing: [], refused: [] });
|
||||
});
|
||||
|
||||
it("leaves an unchanged skill whose source hasn't moved to the usual rehydration, and reports nothing", () => {
|
||||
const { paths, sourceOf, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
cleanSeatSkills(paths);
|
||||
|
||||
expect(followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] }))
|
||||
.toEqual({ refreshed: [], added: [], removed: [], warnings: [] });
|
||||
expect(rehydrateSeatSkills(paths).restored).toEqual([join(paths.skillsRoot, "infra-review", "SKILL.md")]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v1");
|
||||
});
|
||||
|
||||
it("records a moved source even when its bytes are identical, so down can rehydrate it", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
const moved = join(home, "moved");
|
||||
put(join(moved, "SKILL.md"), "v1");
|
||||
followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: moved }] });
|
||||
rmSync(sourceOf("infra-review"), { recursive: true });
|
||||
expect(cleanSeatSkills(paths).kept).toEqual([]);
|
||||
expect(rehydrateSeatSkills(paths).missing).toEqual([]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v1");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("takes an executable-mode change even when a script's bytes are identical", () => {
|
||||
const { paths, sourceOf, launched } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1", "scripts/check.sh": "exit 0" });
|
||||
chmodSync(join(sourceOf("infra-review"), "scripts/check.sh"), 0o755);
|
||||
chmodSync(join(paths.skillsRoot, "infra-review", "scripts/check.sh"), 0o644);
|
||||
followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
expect(statSync(join(paths.skillsRoot, "infra-review", "scripts/check.sh")).mode & 0o777).toBe(0o755);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["claude-code", "codex"])("keeps a seat-local chmod through down, refresh and deselection for %s", (runtime) => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat(runtime);
|
||||
launched("infra-review", { "SKILL.md": "v1", "scripts/check.sh": "exit 0" });
|
||||
const script = join(paths.skillsRoot, "infra-review", "scripts/check.sh");
|
||||
const editedMode = (statSync(script).mode & 0o777) ^ 0o100;
|
||||
chmodSync(script, editedMode);
|
||||
expect(cleanSeatSkills(paths).kept).toContain(script);
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
const result = followSeatSkills(paths, { selected: new Set(["infra-review"]),
|
||||
following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
expect(result.refreshed).toEqual([]);
|
||||
expect(result.warnings.join("\n")).toContain("was edited");
|
||||
expect(rehydrateSeatSkills(paths).kept).toContain(script);
|
||||
expect(copy("infra-review", "scripts/check.sh")).toBe("exit 0");
|
||||
expect(followSeatSkills(paths, { selected: new Set(), following: [] }).removed).toEqual([]);
|
||||
expect(statSync(script).mode & 0o777).toBe(editedMode);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("takes source mode changes after down and records the refreshed mode", () => {
|
||||
const { paths, sourceOf, launched } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1", "scripts/check.sh": "exit 0" });
|
||||
const script = join(paths.skillsRoot, "infra-review", "scripts/check.sh");
|
||||
cleanSeatSkills(paths);
|
||||
chmodSync(join(sourceOf("infra-review"), "scripts/check.sh"), 0o755);
|
||||
expect(followSeatSkills(paths, { selected: new Set(["infra-review"]),
|
||||
following: [{ id: "infra-review", source: sourceOf("infra-review") }] }).refreshed).toEqual(["infra-review"]);
|
||||
expect(statSync(script).mode & 0o777).toBe(0o755);
|
||||
expect(cleanSeatSkills(paths).kept).toEqual([]);
|
||||
rehydrateSeatSkills(paths);
|
||||
expect(statSync(script).mode & 0o777).toBe(0o755);
|
||||
chmodSync(script, 0o644);
|
||||
expect(cleanSeatSkills(paths).kept).toContain(script);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each([false, true])("rehydrates keep_saved with compatible permission records (legacy=%s)", (legacy) => {
|
||||
const { paths, sourceOf, launched } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" }, false);
|
||||
const file = join(paths.skillsRoot, "infra-review", "SKILL.md");
|
||||
const savedMode = statSync(file).mode & 0o777;
|
||||
if (legacy) {
|
||||
const record = JSON.parse(readFileSync(paths.projectionsPath, "utf8"));
|
||||
delete record.files[file].mode;
|
||||
writeFileSync(paths.projectionsPath, JSON.stringify(record));
|
||||
}
|
||||
cleanSeatSkills(paths);
|
||||
chmodSync(join(sourceOf("infra-review"), "SKILL.md"), 0o755);
|
||||
expect(rehydrateSeatSkills(paths).restored).toContain(file);
|
||||
expect(statSync(file).mode & 0o777).toBe(legacy ? 0o755 : savedMode);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("keeps the whole saved skill when a source subdirectory cannot be read", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1", "references/aws.md": "saved reference" });
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
const references = join(sourceOf("infra-review"), "references");
|
||||
chmodSync(references, 0o000);
|
||||
try {
|
||||
const result = followSeatSkills(paths, { selected: new Set(["infra-review"]),
|
||||
following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
expect(result.refreshed).toEqual([]);
|
||||
expect(result.warnings.join(" ")).toContain("can't be read");
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v1");
|
||||
expect(copy("infra-review", "references/aws.md")).toBe("saved reference");
|
||||
} finally { chmodSync(references, 0o755); }
|
||||
});
|
||||
|
||||
it("records a partial refresh so a later restore can finish it", () => {
|
||||
const { paths, sourceOf, launched, put, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1", "old.txt": "old" });
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
put(join(sourceOf("infra-review"), "new.txt"), "new");
|
||||
rmSync(join(sourceOf("infra-review"), "old.txt"));
|
||||
const selection = { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] };
|
||||
const write = fs.writeFileSync;
|
||||
const failure = new Error("fixture disk write failed");
|
||||
const spy = vi.spyOn(fs, "writeFileSync").mockImplementation((file, ...args) => {
|
||||
if (String(file).startsWith(join(paths.skillsRoot, "infra-review", "new.txt.openrig-"))) throw failure;
|
||||
return write(file, ...args);
|
||||
});
|
||||
try { expect(() => followSeatSkills(paths, selection)).toThrow(failure); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(Object.keys(JSON.parse(readFileSync(paths.projectionsPath, "utf8")).files))
|
||||
.toEqual([join(paths.skillsRoot, "infra-review", "SKILL.md")]);
|
||||
expect(followSeatSkills(paths, selection).warnings).toEqual([]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v2");
|
||||
expect(copy("infra-review", "new.txt")).toBe("new");
|
||||
});
|
||||
|
||||
it.each([false, true])("reports an ownership-write failure without hiding a refresh error (refresh fails=%s)", failRefresh => {
|
||||
const { paths, sourceOf, launched, put } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
const write = fs.writeFileSync;
|
||||
const refreshError = new Error("fixture refresh failed");
|
||||
const spy = vi.spyOn(fs, "writeFileSync").mockImplementation((file, ...args) => {
|
||||
if (String(file).startsWith(`${paths.projectionsPath}.openrig-`)) throw new Error("fixture record failed");
|
||||
if (failRefresh && String(file).startsWith(join(paths.skillsRoot, "infra-review", "SKILL.md.openrig-"))) throw refreshError;
|
||||
return write(file, ...args);
|
||||
});
|
||||
const refresh = () => followSeatSkills(paths, { selected: new Set(["infra-review"]),
|
||||
following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
try {
|
||||
if (failRefresh) expect(refresh).toThrow(refreshError);
|
||||
else expect(refresh().warnings.join(" ")).toContain("could not record skill ownership");
|
||||
} finally { spy.mockRestore(); }
|
||||
});
|
||||
|
||||
it("names a missing following skill when rehydration cannot recover it", () => {
|
||||
const { paths, sourceOf, launched } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
cleanSeatSkills(paths);
|
||||
rmSync(sourceOf("infra-review"), { recursive: true });
|
||||
const replay = seatSkillReplay({ paths, rehydration: rehydrateSeatSkills(paths), files: [],
|
||||
entries: [{ category: "skill", effectiveId: "infra-review", absolutePath: sourceOf("infra-review") }] });
|
||||
expect(replay.skipEntryIds.has("infra-review")).toBe(true);
|
||||
expect(replay.warnings).toEqual(["Restore: infra-review not projected again: its following copy is missing and could not be restored"]);
|
||||
});
|
||||
|
||||
it.each(["loadout", "both"])("reconciles %s ownership through an unchanged restore, source move, file removal and deselection", ownership => {
|
||||
const { paths, sourceOf, launched, put, copy } = seat();
|
||||
launched("review", { "SKILL.md": "v1", "removed.txt": "old reference" });
|
||||
const original = sourceOf("review");
|
||||
const untouched = { id: "other", sourceDir: sourceOf("other"), target: join(paths.skillsRoot, "other"), files: {} };
|
||||
writeFileSync(paths.manifestPath, JSON.stringify({ topologySelections: { team: ["review"] }, skills: [untouched, {
|
||||
id: "review", sourceDir: original, target: join(paths.skillsRoot, "review"),
|
||||
files: Object.fromEntries([['SKILL.md', 'v1'], ['removed.txt', 'old reference']].map(([name, bytes]) =>
|
||||
[name, createHash("sha256").update(bytes!).digest("hex")])),
|
||||
}] }));
|
||||
if (ownership === "loadout") writeFileSync(paths.projectionsPath, JSON.stringify({ files: {}, following: { review: { source: original } } }));
|
||||
const selected = new Set(["review"]);
|
||||
expect(followSeatSkills(paths, { selected, following: [{ id: "review", source: original }] }))
|
||||
.toEqual({ refreshed: [], added: [], removed: [], warnings: [] });
|
||||
expect(JSON.parse(readFileSync(paths.manifestPath, "utf8")))
|
||||
.toEqual({ topologySelections: { team: ["review"] }, skills: [untouched] });
|
||||
const moved = join(home, "moved-review");
|
||||
put(join(moved, "SKILL.md"), "v2");
|
||||
put(join(moved, "new.txt"), "new reference");
|
||||
expect(followSeatSkills(paths, { selected, following: [{ id: "review", source: moved }] }).refreshed).toEqual(["review"]);
|
||||
cleanSeatSkills(paths);
|
||||
expect(rehydrateSeatSkills(paths).missing).toEqual([]);
|
||||
expect(copy("review", "SKILL.md")).toBe("v2");
|
||||
expect(copy("review", "new.txt")).toBe("new reference");
|
||||
expect(existsSync(join(paths.skillsRoot, "review", "removed.txt"))).toBe(false);
|
||||
expect(followSeatSkills(paths, { selected: new Set(), following: [] }).removed).toEqual(["review"]);
|
||||
expect(rehydrateSeatSkills(paths).restored).toEqual([]);
|
||||
expect(existsSync(join(paths.skillsRoot, "review"))).toBe(false);
|
||||
expect(readFileSync(join(original, "removed.txt"), "utf8")).toBe("old reference");
|
||||
});
|
||||
|
||||
it("keeps a copy the seat edited, or added a file to, and names it", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
launched("aws", { "SKILL.md": "aws v1" });
|
||||
put(join(paths.skillsRoot, "infra-review", "SKILL.md"), "the seat's edit");
|
||||
put(join(paths.skillsRoot, "aws", "notes.md"), "the seat's notes");
|
||||
put(join(sourceOf("infra-review"), "SKILL.md"), "v2");
|
||||
put(join(sourceOf("aws"), "SKILL.md"), "aws v2");
|
||||
|
||||
const out = followSeatSkills(paths, { selected: new Set(["infra-review", "aws"]),
|
||||
following: [{ id: "infra-review", source: sourceOf("infra-review") }, { id: "aws", source: sourceOf("aws") }] });
|
||||
|
||||
expect(out.refreshed).toEqual([]);
|
||||
expect(out.warnings).toEqual([
|
||||
`Restore: kept ${join(paths.skillsRoot, "infra-review")}: ${join(paths.skillsRoot, "infra-review", "SKILL.md")} was edited; it follows its source (on_restore: follow_source), but an edit is never overwritten`,
|
||||
`Restore: kept ${join(paths.skillsRoot, "aws")}: ${join(paths.skillsRoot, "aws", "notes.md")} is not a file OpenRig wrote; it follows its source (on_restore: follow_source), but an edit is never overwritten`,
|
||||
]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("the seat's edit");
|
||||
expect(copy("aws", "SKILL.md")).toBe("aws v1");
|
||||
});
|
||||
|
||||
it("copies in a following skill the profile newly selects, unless something not OpenRig's is already there", () => {
|
||||
const { paths, sourceOf, put, copy } = seat();
|
||||
put(join(sourceOf("aws"), "SKILL.md"), "aws");
|
||||
put(join(sourceOf("mine"), "SKILL.md"), "from the source");
|
||||
put(join(paths.skillsRoot, "mine", "SKILL.md"), "the person's own");
|
||||
|
||||
const out = followSeatSkills(paths, { selected: new Set(["aws", "mine"]),
|
||||
following: [{ id: "aws", source: sourceOf("aws") }, { id: "mine", source: sourceOf("mine") }] });
|
||||
|
||||
expect(out.added).toEqual(["aws"]);
|
||||
expect(out.warnings).toEqual([
|
||||
`Restore: kept ${join(paths.skillsRoot, "mine")}: ${join(paths.skillsRoot, "mine", "SKILL.md")} is not a file OpenRig wrote; it follows its source (on_restore: follow_source), but an edit is never overwritten`]);
|
||||
expect(copy("aws", "SKILL.md")).toBe("aws");
|
||||
expect(copy("mine", "SKILL.md")).toBe("the person's own");
|
||||
// Recorded as OpenRig's, so rig down removes it like any other.
|
||||
expect(cleanSeatSkills(paths).removed).toContain(join(paths.skillsRoot, "aws", "SKILL.md"));
|
||||
});
|
||||
|
||||
it("removes a following skill the profile no longer selects while unchanged; an edited one is kept", () => {
|
||||
const { paths, sourceOf, put, launched } = seat();
|
||||
launched("aws-cdk", { "SKILL.md": "cdk", "scripts/synth.sh": "synth" });
|
||||
launched("github-oidc-aws", { "SKILL.md": "oidc" });
|
||||
put(join(paths.skillsRoot, "github-oidc-aws", "SKILL.md"), "the seat's edit");
|
||||
|
||||
const out = followSeatSkills(paths, { selected: new Set(), following: [] });
|
||||
|
||||
expect(out.removed).toEqual(["aws-cdk"]);
|
||||
expect(existsSync(join(paths.skillsRoot, "aws-cdk"))).toBe(false);
|
||||
expect(out.warnings).toEqual([
|
||||
`Restore: kept ${join(paths.skillsRoot, "github-oidc-aws")}: ${join(paths.skillsRoot, "github-oidc-aws", "SKILL.md")} was edited, and it is no longer selected; it follows its source (on_restore: follow_source), but an edit is never overwritten`]);
|
||||
// Nothing of the removed skill is left for a later restore to put back.
|
||||
cleanSeatSkills(paths);
|
||||
expect(rehydrateSeatSkills(paths).restored).toEqual([]);
|
||||
// No spec resolution is needed to keep a previously removed skill out of an older startup plan.
|
||||
expect(seatSkillReplay({ paths, entries: [{ category: "skill", effectiveId: "aws-cdk", absolutePath: sourceOf("aws-cdk") }],
|
||||
files: [], rehydration: { missing: [], refused: [] } }).skipEntryIds).toContain("aws-cdk");
|
||||
// A later fresh restore may still carry the old startup plan; keep suppressing its removed entry.
|
||||
expect(followSeatSkills(paths, { selected: new Set(), following: [] }).removed).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves skills that keep their saved copy alone, selected or not, and stops following one switched to keep_saved", () => {
|
||||
const { paths, sourceOf, put, launched, copy } = seat();
|
||||
launched("tdd", { "SKILL.md": "tdd v1" }, false);
|
||||
launched("old-pinned", { "SKILL.md": "old" }, false);
|
||||
launched("repo-docs", { "SKILL.md": "docs v1" });
|
||||
put(join(sourceOf("tdd"), "SKILL.md"), "tdd v2");
|
||||
put(join(sourceOf("repo-docs"), "SKILL.md"), "docs v2");
|
||||
|
||||
expect(followSeatSkills(paths, { selected: new Set(["tdd", "repo-docs"]), following: [] }))
|
||||
.toEqual({ refreshed: [], added: [], removed: [], warnings: [] });
|
||||
expect(copy("tdd", "SKILL.md")).toBe("tdd v1");
|
||||
expect(copy("old-pinned", "SKILL.md")).toBe("old");
|
||||
expect(copy("repo-docs", "SKILL.md")).toBe("docs v1");
|
||||
// repo-docs no longer follows: deselecting it later keeps it, like any saved copy.
|
||||
expect(followSeatSkills(paths, { selected: new Set(), following: [] }).removed).toEqual([]);
|
||||
expect(copy("repo-docs", "SKILL.md")).toBe("docs v1");
|
||||
});
|
||||
|
||||
it("keeps the saved copy when the source can't be read as a skill, and names it", () => {
|
||||
const { paths, sourceOf, launched, copy } = seat();
|
||||
launched("infra-review", { "SKILL.md": "v1" });
|
||||
rmSync(sourceOf("infra-review"), { recursive: true });
|
||||
|
||||
const out = followSeatSkills(paths, { selected: new Set(["infra-review"]), following: [{ id: "infra-review", source: sourceOf("infra-review") }] });
|
||||
|
||||
expect(out.warnings).toEqual([`Restore: infra-review follows its source, but ${sourceOf("infra-review")} can't be read as a skill; its saved copy is kept`]);
|
||||
expect(copy("infra-review", "SKILL.md")).toBe("v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a launch records which of a seat's skills follow their source", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "seat-skills-follow-launch-")); });
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
const realFs = {
|
||||
readFile: (p: string) => readFileSync(p, "utf8"), writeFile: (p: string, c: string) => writeFileSync(p, c),
|
||||
exists: (p: string) => existsSync(p), mkdirp: (p: string) => { mkdirSync(p, { recursive: true }); },
|
||||
copyFile: (a: string, b: string) => writeFileSync(b, readFileSync(a)),
|
||||
statMode: (p: string) => statSync(p).mode, chmod: chmodSync,
|
||||
listFiles: (p: string) => {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string, rel: string) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name), join(rel, e.name)); else out.push(join(rel, e.name));
|
||||
}
|
||||
};
|
||||
walk(p, "");
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
for (const runtime of ["claude-code", "codex"] as const) {
|
||||
it.each(["projection", "loadout", "unowned", "edited", "unreadable", "record-failure", ...(process.platform === "win32" ? [] : ["linked"])])(`${runtime}: records no-op policy only for unchanged owned files (%s)`, async ownership => {
|
||||
const paths = seatSkillPaths({ rigId: "rig-1", logicalId: "dev.impl", runtime }, home);
|
||||
if (runtime === "claude-code") ensureSeatSkillPlugin(paths);
|
||||
const source = join(home, "review");
|
||||
const target = join(paths.skillsRoot, "review");
|
||||
for (const dir of [source, target]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), "review");
|
||||
writeFileSync(join(dir, "helper.txt"), "original");
|
||||
}
|
||||
if (ownership === "loadout") {
|
||||
writeFileSync(paths.manifestPath, JSON.stringify({ skills: [{ target, sourceDir: source, files: {
|
||||
"SKILL.md": createHash("sha256").update("review").digest("hex"),
|
||||
"helper.txt": createHash("sha256").update("original").digest("hex"),
|
||||
} }] }));
|
||||
} else if (ownership !== "unowned") {
|
||||
for (const file of ["SKILL.md", "helper.txt"]) recordSeatSkillWrite(paths.skillsRoot, join(target, file), join(source, file), readFileSync(join(source, file)));
|
||||
}
|
||||
if (ownership === "edited") writeFileSync(join(target, "helper.txt"), "seat edit");
|
||||
if (ownership === "linked") { rmSync(join(target, "helper.txt")); symlinkSync(join(source, "helper.txt"), join(target, "helper.txt")); }
|
||||
const adapter = runtime === "codex" ? new CodexRuntimeAdapter({ tmux: {} as never, fsOps: realFs as never })
|
||||
: new ClaudeCodeAdapter({ tmux: {} as never, fsOps: realFs });
|
||||
const binding = { cwd: home, seatSkillsRoot: paths.skillsRoot } as NodeBinding;
|
||||
const following = () => existsSync(paths.projectionsPath) ? JSON.parse(readFileSync(paths.projectionsPath, "utf8")).following : undefined;
|
||||
for (const onRestore of ["follow_source", "keep_saved"] as const) {
|
||||
const result = planProjection({ config: { runtime, cwd: home, restorePolicy: "resume_if_possible", startup: { files: [], actions: [] },
|
||||
resolvedSpecName: "team", resolvedSpecVersion: "1", resolvedSpecHash: "fixture",
|
||||
selectedResources: { guidance: [], plugins: [], runtimeResources: [], subagents: [], skills: [
|
||||
{ effectiveId: "review", sourceSpec: "team", sourcePath: home, resource: { id: "review", path: source, onRestore } },
|
||||
] },
|
||||
}, collisions: [], fsOps: realFs, resolveTargetPath: () => join(target, "SKILL.md") });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error(result.errors.join("; "));
|
||||
expect(result.plan.entries[0]?.classification).toBe("no_op");
|
||||
const writes = vi.spyOn(realFs, "writeFile");
|
||||
const storedRead = fs.readFileSync;
|
||||
const reads = vi.spyOn(fs, "readFileSync").mockImplementation((...args: Parameters<typeof fs.readFileSync>) => {
|
||||
if (ownership === "unreadable" && args[0] === join(target, "helper.txt")) {
|
||||
throw Object.assign(new Error("Permission denied"), { code: "EACCES" });
|
||||
}
|
||||
return storedRead(...args);
|
||||
});
|
||||
const storedWrite = fs.writeFileSync;
|
||||
const recordWrites = vi.spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters<typeof fs.writeFileSync>) => {
|
||||
if (ownership === "record-failure" && String(args[0]).startsWith(`${paths.projectionsPath}.openrig-`)) throw new Error("Read-only filesystem");
|
||||
return storedWrite(...args);
|
||||
});
|
||||
try {
|
||||
expect(await adapter.project(result.plan, binding)).toMatchObject({ projected: [], skipped: ["review"], failed: [] });
|
||||
expect(writes).not.toHaveBeenCalled();
|
||||
if (onRestore === "follow_source" && ownership === "unreadable") {
|
||||
expect(reads.mock.calls.some(([file]) => file === join(target, "helper.txt"))).toBe(true);
|
||||
}
|
||||
if (onRestore === "keep_saved") {
|
||||
expect(reads.mock.calls.some(([file]) => String(file).startsWith(target))).toBe(false);
|
||||
}
|
||||
} finally { writes.mockRestore(); reads.mockRestore(); recordWrites.mockRestore(); }
|
||||
expect(readFileSync(join(target, "SKILL.md"), "utf8")).toBe("review");
|
||||
expect(following()?.review).toEqual(onRestore === "follow_source" && ["projection", "loadout"].includes(ownership) ? { source } : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${runtime}: marks a follow_source skill as following, and unmarks it when it is launched as keep_saved`, async () => {
|
||||
const paths = seatSkillPaths({ rigId: "rig-1", logicalId: "dev.impl", runtime }, home);
|
||||
if (runtime === "claude-code") ensureSeatSkillPlugin(paths);
|
||||
const project = join(home, "project");
|
||||
mkdirSync(project, { recursive: true });
|
||||
const source = (id: string) => {
|
||||
const dir = join(home, "skills", id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), `---\nname: ${id}\ndescription: d\n---\nbody\n`);
|
||||
if (process.platform !== "win32") {
|
||||
const shared = join(home, `${id}-shared-reference.md`);
|
||||
writeFileSync(shared, "shared v1");
|
||||
if (!existsSync(join(dir, "reference.md"))) symlinkSync(shared, join(dir, "reference.md"));
|
||||
}
|
||||
chmodSync(join(dir, "SKILL.md"), 0o640);
|
||||
return dir;
|
||||
};
|
||||
const adapter = runtime === "codex"
|
||||
? new CodexRuntimeAdapter({ tmux: {} as never, fsOps: realFs as never })
|
||||
: new ClaudeCodeAdapter({ tmux: {} as never, fsOps: realFs });
|
||||
const binding = { id: "b", nodeId: "n", tmuxSession: null, tmuxWindow: null, tmuxPane: null, cmuxWorkspace: null, cmuxSurface: null,
|
||||
updatedAt: "", cwd: project, seatSkillsRoot: paths.skillsRoot } as NodeBinding;
|
||||
const launch = (entries: Array<{ id: string; onRestore?: "follow_source" }>) => adapter.project({ runtime, cwd: project,
|
||||
entries: entries.map(({ id, onRestore }) => ({ effectiveId: id, category: "skill", absolutePath: source(id), classification: "safe_projection", ...(onRestore ? { onRestore } : {}) })),
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] } as never, binding);
|
||||
const following = () => (JSON.parse(readFileSync(paths.projectionsPath, "utf8")) as { following?: Record<string, { source: string }> }).following;
|
||||
|
||||
expect((await launch([{ id: "infra-review", onRestore: "follow_source" }, { id: "tdd" }])).failed).toEqual([]);
|
||||
expect(following()).toEqual({ "infra-review": { source: join(home, "skills", "infra-review") } });
|
||||
const projected = join(paths.skillsRoot, "infra-review", "SKILL.md");
|
||||
if (process.platform !== "win32") expect(JSON.parse(readFileSync(paths.projectionsPath, "utf8")).files[projected].mode).toBe(0o640);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const reference = join(paths.skillsRoot, "infra-review", "reference.md");
|
||||
expect(readFileSync(reference, "utf8")).toBe("shared v1");
|
||||
expect(fs.lstatSync(reference).isSymbolicLink()).toBe(false);
|
||||
writeFileSync(join(home, "infra-review-shared-reference.md"), "shared v2");
|
||||
followSeatSkills(paths, { selected: new Set(["infra-review", "tdd"]),
|
||||
following: [{ id: "infra-review", source: join(home, "skills", "infra-review") }] });
|
||||
expect(readFileSync(reference, "utf8")).toBe("shared v2");
|
||||
}
|
||||
await launch([{ id: "infra-review" }, { id: "tdd" }]);
|
||||
expect(following()).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("a restore reads a seat's skill selection from its spec as it is now, through its launch recipe", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "seat-skills-follow-select-")); });
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
/** Create a persisted seat recipe whose source agent spec can change independently. */
|
||||
function seatWithRecipe(agentYaml: string | undefined, opts: { recipe?: boolean } = {}) {
|
||||
const db = createFullTestDb();
|
||||
const rigRoot = mkdtempSync(join(home, "rig-"));
|
||||
if (agentYaml !== undefined) { mkdirSync(join(rigRoot, "agents", "impl"), { recursive: true }); writeFileSync(join(rigRoot, "agents", "impl", "agent.yaml"), agentYaml); }
|
||||
const setup = createTestApp(db, { podInstantiatorFsOps: { exists: (p) => existsSync(p), readFile: (p) => readFileSync(p, "utf8") } });
|
||||
const rig = setup.rigRepo.createRig("follow-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("pod-follow", rig.id, "dev", "Development");
|
||||
const node = setup.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", podId: "pod-follow", cwd: rigRoot,
|
||||
...(opts.recipe === false ? {} : { launchRecipe: { version: 1 as const, rigRoot, rigSpec: { version: "0.2", name: "follow-rig",
|
||||
pods: [{ id: "dev", label: "Development", members: [{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." }], edges: [] }],
|
||||
edges: [] } } }) });
|
||||
return { db, setup, rigId: rig.id, nodeId: node.id, rigRoot };
|
||||
}
|
||||
|
||||
it("lists every skill the profile selects today, and which follow their source, with the source's full path", async () => {
|
||||
const { db, setup, rigId, nodeId, rigRoot } = seatWithRecipe(`name: impl
|
||||
version: "1.0"
|
||||
resources:
|
||||
skills:
|
||||
- id: infra-review
|
||||
path: skills/infra-review
|
||||
on_restore: follow_source
|
||||
- id: tdd
|
||||
path: skills/tdd
|
||||
- id: unused
|
||||
path: skills/unused
|
||||
on_restore: follow_source
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [infra-review, tdd]
|
||||
`);
|
||||
for (const id of ["infra-review", "tdd", "unused"]) {
|
||||
mkdirSync(join(rigRoot, "agents", "impl", "skills", id), { recursive: true });
|
||||
writeFileSync(join(rigRoot, "agents", "impl", "skills", id, "SKILL.md"), `---\nname: ${id}\ndescription: d\n---\n`);
|
||||
}
|
||||
try {
|
||||
const out = await setup.podInstantiator.seatSkillSelection(rigId, nodeId);
|
||||
expect(out.ok).toBe(true);
|
||||
if (!out.ok) return;
|
||||
expect([...out.selection.selected].sort()).toEqual(["infra-review", "tdd"]);
|
||||
expect(out.selection.following).toEqual([{ id: "infra-review", source: join(rigRoot, "agents", "impl", "skills", "infra-review") }]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("resolves local:. against the saved launch directory, not a same-name spec in the seat's cwd", async () => {
|
||||
const { db, setup, rigId, nodeId, rigRoot } = seatWithRecipe(undefined);
|
||||
const spec = (id: string) => `name: impl\nversion: "1.0"\nresources:\n skills:\n - id: ${id}\n path: skills/${id}\n on_restore: follow_source\nprofiles:\n default:\n uses:\n skills: [${id}]\n`;
|
||||
const cwd = join(rigRoot, "other");
|
||||
for (const [folder, id] of [[rigRoot, "correct"], [cwd, "wrong"]]) {
|
||||
mkdirSync(join(folder!, "skills", id!), { recursive: true });
|
||||
writeFileSync(join(folder!, "agent.yaml"), spec(id!));
|
||||
writeFileSync(join(folder!, "skills", id!, "SKILL.md"), `---\nname: ${id}\ndescription: d\n---\n`);
|
||||
}
|
||||
const recipe = setup.rigRepo.getNodeLaunchRecipe(nodeId)!;
|
||||
recipe.rigSpec.pods[0]!.members[0]!.agentRef = "local:.";
|
||||
db.prepare("UPDATE nodes SET cwd = ?, launch_recipe_json = ? WHERE id = ?").run(cwd, JSON.stringify(recipe), nodeId);
|
||||
try {
|
||||
const result = await setup.podInstantiator.seatSkillSelection(rigId, nodeId);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.selection.following).toEqual([{ id: "correct", source: join(rigRoot, "skills", "correct") }]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("finds a following skill declared in an imported spec and selected by its qualified name", async () => {
|
||||
const { db, setup, rigId, nodeId, rigRoot } = seatWithRecipe(`name: impl
|
||||
version: "1.0"
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [team-skills:infra-review, team-skills:tdd]
|
||||
`);
|
||||
const shared = join(rigRoot, "shared");
|
||||
mkdirSync(shared, { recursive: true });
|
||||
writeFileSync(join(shared, "agent.yaml"), `name: team-skills
|
||||
version: "1.0"
|
||||
resources:
|
||||
skills:
|
||||
- id: infra-review
|
||||
path: vendor/infra-review
|
||||
on_restore: follow_source
|
||||
- id: tdd
|
||||
path: vendor/tdd
|
||||
`);
|
||||
for (const id of ["infra-review", "tdd"]) {
|
||||
mkdirSync(join(shared, "vendor", id), { recursive: true });
|
||||
writeFileSync(join(shared, "vendor", id, "SKILL.md"), `---\nname: ${id}\ndescription: d\n---\n`);
|
||||
}
|
||||
try {
|
||||
const out = await setup.podInstantiator.seatSkillSelection(rigId, nodeId);
|
||||
expect(out.ok, JSON.stringify(out)).toBe(true);
|
||||
if (!out.ok) return;
|
||||
// An imported skill keeps its qualified name, which is also its folder in the seat's storage.
|
||||
expect([...out.selection.selected].sort()).toEqual(["team-skills:infra-review", "team-skills:tdd"]);
|
||||
expect(out.selection.following).toEqual([{ id: "team-skills:infra-review", source: join(shared, "vendor", "infra-review") }]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("says why when it can't: no launch recipe, or an agent spec that is gone or invalid", async () => {
|
||||
const cases = [
|
||||
seatWithRecipe(undefined, { recipe: false }),
|
||||
seatWithRecipe(undefined),
|
||||
seatWithRecipe(`name: impl\nversion: "1.0"\nresources:\n skills:\n - id: x\n on_restore: follow_source\nprofiles:\n default: {}\n`),
|
||||
];
|
||||
try {
|
||||
const reasons = [];
|
||||
for (const c of cases) {
|
||||
const out = await c.setup.podInstantiator.seatSkillSelection(c.rigId, c.nodeId);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) reasons.push(out.reason);
|
||||
}
|
||||
expect(reasons[0]).toContain("no launch recipe");
|
||||
expect(reasons[1]).toContain("agent.yaml");
|
||||
expect(reasons[2]).toContain("path");
|
||||
} finally { for (const c of cases) c.db.close(); }
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,7 @@ describe("F4: a restore puts back a helper with its executable mode", () => {
|
||||
const { paths, source, target } = claudeSeat();
|
||||
chmodSync(join(source, "scripts/helper.sh"), 0o755);
|
||||
chmodSync(join(target, "scripts/helper.sh"), 0o755);
|
||||
recordSeatSkillWrite(paths.skillsRoot, join(target, "scripts/helper.sh"), join(source, "scripts/helper.sh"), readFileSync(join(target, "scripts/helper.sh")));
|
||||
cleanSeatSkills(paths);
|
||||
expect(existsSync(join(target, "scripts/helper.sh"))).toBe(false);
|
||||
expect(rehydrateSeatSkills(paths).restored).toContain(join(target, "scripts/helper.sh"));
|
||||
|
||||
Reference in new issue
Block a user