Skip to content

Commit f3d1579

Browse files
authored
feat: fix skill visibility pipeline — PATH, nudge, name alignment, gateway restart (#1055)
1 parent 15286ef commit f3d1579

35 files changed

Lines changed: 3331 additions & 1119 deletions

apps/controller/src/app/container.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,15 @@ export async function createContainer(): Promise<ControllerContainer> {
9090
const runtimeModelWriter = new OpenClawRuntimeModelWriter(env);
9191
const creditGuardStateWriter = new CreditGuardStateWriter(env);
9292
const templateWriter = new WorkspaceTemplateWriter(env);
93-
const watchTrigger = new OpenClawWatchTrigger(env);
9493
const gatewayClient = new GatewayClient(env);
9594
const sessionsRuntime = new SessionsRuntime(env);
9695
const runtimeHealth = new RuntimeHealth(env);
9796
const runtimeState = createRuntimeState();
97+
// Construct openclawProcess before watchTrigger so the watch trigger can
98+
// delegate gateway restarts to OpenClawProcessManager.restart() instead of
99+
// re-implementing the dev-vs-launchd branching inline.
98100
const openclawProcess = new OpenClawProcessManager(env);
101+
const watchTrigger = new OpenClawWatchTrigger(env, openclawProcess);
99102
const wsClient = new OpenClawWsClient(env);
100103
const gatewayService = new OpenClawGatewayService(wsClient, runtimeState);
101104
const channelFallbackService = new ChannelFallbackService(

apps/controller/src/runtime/openclaw-auth-profiles-store.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@ export class OpenClawAuthProfilesStore {
9292
}
9393
}
9494

95+
sharedAuthProfilesPath(): string {
96+
return path.join(this.env.openclawStateDir, "auth-profiles.json");
97+
}
98+
99+
async listWritableAuthProfilesPaths(): Promise<string[]> {
100+
return [
101+
this.sharedAuthProfilesPath(),
102+
...(await this.listAgentAuthProfilesPaths()),
103+
];
104+
}
105+
106+
async listExistingAuthProfilesPaths(): Promise<string[]> {
107+
const candidates = await this.listWritableAuthProfilesPaths();
108+
const existingPaths: string[] = [];
109+
110+
for (const filePath of candidates) {
111+
const data = await this.readAuthProfiles(filePath, { missingOk: true });
112+
if (data) {
113+
existingPaths.push(filePath);
114+
}
115+
}
116+
117+
return existingPaths;
118+
}
119+
95120
authProfilesPathForWorkspace(workspace: string): string {
96121
return path.join(workspace, "agent", "auth-profiles.json");
97122
}

apps/controller/src/runtime/openclaw-auth-profiles-writer.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ export class OpenClawAuthProfilesWriter {
110110
string,
111111
AuthProfileRecord
112112
>;
113+
const sharedProfiles =
114+
(
115+
await this.authProfilesStore.readAuthProfiles(
116+
this.authProfilesStore.sharedAuthProfilesPath(),
117+
{ missingOk: true },
118+
)
119+
)?.profiles ?? {};
120+
const sharedNonApiProfiles = Object.fromEntries(
121+
Object.entries(sharedProfiles).filter(
122+
([, profile]) => !isApiKeyProfile(profile),
123+
),
124+
);
125+
113126
await Promise.all(
114127
(config.agents?.list ?? []).map(async (agent) => {
115128
if (
@@ -126,7 +139,9 @@ export class OpenClawAuthProfilesWriter {
126139
await this.authProfilesStore.updateAuthProfiles(
127140
authProfilesPath,
128141
async (existing) => {
129-
const preservedProfiles: Record<string, unknown> = {};
142+
const preservedProfiles: Record<string, unknown> = {
143+
...sharedNonApiProfiles,
144+
};
130145
for (const [key, profile] of Object.entries(existing.profiles)) {
131146
if (!isApiKeyProfile(profile)) {
132147
preservedProfiles[key] = profile;
Lines changed: 181 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,106 @@
1-
import { appendFile } from "node:fs/promises";
1+
import type { Dirent } from "node:fs";
2+
import {
3+
appendFile,
4+
mkdir,
5+
readFile,
6+
readdir,
7+
utimes,
8+
writeFile,
9+
} from "node:fs/promises";
210
import path from "node:path";
311
import type { ControllerEnv } from "../app/env.js";
12+
import { logger } from "../lib/logger.js";
13+
import type { OpenClawProcessManager } from "./openclaw-process.js";
14+
15+
/**
16+
* Dotfile sentinel inside the skills directory used as the single nudge
17+
* point for OpenClaw's skills chokidar watcher. The dot prefix keeps it
18+
* out of every "list skills" code path, which all filter by
19+
* `isDirectory()` + presence of `SKILL.md`.
20+
*/
21+
const SKILLS_NUDGE_MARKER_NAME = ".controller-nudge";
22+
const SESSIONS_INDEX_NAME = "sessions.json";
23+
24+
type SessionIndexRecord = Record<string, unknown>;
425

526
export class OpenClawWatchTrigger {
6-
constructor(private readonly env: ControllerEnv) {}
27+
constructor(
28+
private readonly env: ControllerEnv,
29+
private readonly openclawProcess: OpenClawProcessManager,
30+
) {}
731

832
async touchConfig(): Promise<void> {
933
await this.touchFile(this.env.openclawConfigPath);
1034
}
1135

12-
async touchSkill(slug: string): Promise<void> {
13-
await this.touchFile(
14-
path.join(this.env.openclawSkillsDir, slug, "SKILL.md"),
36+
/**
37+
* Fire a synthetic chokidar `change` event in OpenClaw's skills watcher
38+
* so it bumps `snapshotVersion`. Live sessions drop their cached skills
39+
* snapshot and rebuild it on the next agent turn, picking up any new
40+
* agent allowlist or on-disk skill content.
41+
*
42+
* This is the single converged nudge primitive for the skills pipeline.
43+
* Every code path that mutates the agent skill allowlist or skill files
44+
* (config push, install, uninstall, edit, …) should funnel through here.
45+
*
46+
* @param reason short identifier of the caller for troubleshooting logs,
47+
* e.g. `"config-pushed"`, `"skill-installed"`, `"skill-uninstalled"`.
48+
*/
49+
async nudgeSkillsWatcher(reason: string): Promise<void> {
50+
const marker = path.join(
51+
this.env.openclawSkillsDir,
52+
SKILLS_NUDGE_MARKER_NAME,
1553
);
54+
try {
55+
const invalidatedSessions = await this.invalidateSessionSkillSnapshots();
56+
await mkdir(this.env.openclawSkillsDir, { recursive: true });
57+
// Ensure the marker exists. `flag: "a"` creates on first run, no-op
58+
// afterwards. The dot prefix keeps the marker out of every "list
59+
// skills" reader, all of which filter by isDirectory() + SKILL.md.
60+
await writeFile(marker, "", { flag: "a" });
61+
// Explicit mtime bump. `writeFile(..., "")` writes zero bytes and
62+
// does not reliably update mtime on macOS APFS, which would
63+
// silently skip the chokidar `change` event we depend on.
64+
const now = new Date();
65+
await utimes(marker, now, now);
66+
// Delegate the actual gateway restart to OpenClawProcessManager so the
67+
// dev-vs-launchd branching lives in exactly one place. `restart()`
68+
// throws on launchctl failure; we absorb it here so the nudge stays
69+
// best-effort (the snapshot invalidation + marker bump above are
70+
// already on disk and will be picked up on the next gateway boot).
71+
let gatewayRestarted = false;
72+
try {
73+
await this.openclawProcess.restart(reason);
74+
gatewayRestarted = true;
75+
} catch (err) {
76+
logger.warn(
77+
{
78+
reason,
79+
err: err instanceof Error ? err.message : String(err),
80+
},
81+
"openclaw gateway restart failed during skills watcher nudge",
82+
);
83+
}
84+
logger.info(
85+
{
86+
reason,
87+
marker,
88+
mtime: now.toISOString(),
89+
invalidatedSessions,
90+
gatewayRestarted,
91+
},
92+
"openclaw skills watcher nudged",
93+
);
94+
} catch (error) {
95+
logger.warn(
96+
{
97+
reason,
98+
marker,
99+
err: error instanceof Error ? error.message : String(error),
100+
},
101+
"openclaw skills watcher nudge failed",
102+
);
103+
}
16104
}
17105

18106
private async touchFile(filePath: string): Promise<void> {
@@ -22,4 +110,92 @@ export class OpenClawWatchTrigger {
22110
return;
23111
}
24112
}
113+
114+
private async invalidateSessionSkillSnapshots(): Promise<number> {
115+
const agentsDir = path.join(this.env.openclawStateDir, "agents");
116+
let invalidatedSessions = 0;
117+
118+
let agentEntries: Dirent[];
119+
try {
120+
agentEntries = await readdir(agentsDir, { withFileTypes: true });
121+
} catch {
122+
return 0;
123+
}
124+
125+
for (const agentEntry of agentEntries) {
126+
if (!agentEntry.isDirectory()) {
127+
continue;
128+
}
129+
130+
const sessionsIndexPath = path.join(
131+
agentsDir,
132+
agentEntry.name,
133+
"sessions",
134+
SESSIONS_INDEX_NAME,
135+
);
136+
const currentIndex = await this.readSessionsIndex(sessionsIndexPath);
137+
if (currentIndex == null) {
138+
continue;
139+
}
140+
141+
let changed = false;
142+
const nextIndex = Object.fromEntries(
143+
Object.entries(currentIndex).map(([sessionKey, sessionValue]) => {
144+
if (!this.hasSkillsSnapshot(sessionValue)) {
145+
return [sessionKey, sessionValue];
146+
}
147+
148+
changed = true;
149+
invalidatedSessions += 1;
150+
const { skillsSnapshot: _skillsSnapshot, ...rest } = sessionValue;
151+
return [sessionKey, rest];
152+
}),
153+
);
154+
155+
if (!changed) {
156+
continue;
157+
}
158+
159+
await writeFile(
160+
sessionsIndexPath,
161+
`${JSON.stringify(nextIndex, null, 2)}\n`,
162+
"utf8",
163+
);
164+
}
165+
166+
return invalidatedSessions;
167+
}
168+
169+
private async readSessionsIndex(
170+
sessionsIndexPath: string,
171+
): Promise<Record<string, SessionIndexRecord> | null> {
172+
try {
173+
const raw = await readFile(sessionsIndexPath, "utf8");
174+
const parsed = JSON.parse(raw) as unknown;
175+
if (
176+
typeof parsed !== "object" ||
177+
parsed == null ||
178+
Array.isArray(parsed)
179+
) {
180+
return null;
181+
}
182+
183+
return Object.fromEntries(
184+
Object.entries(parsed).filter(
185+
(entry): entry is [string, SessionIndexRecord] =>
186+
typeof entry[1] === "object" &&
187+
entry[1] != null &&
188+
!Array.isArray(entry[1]),
189+
),
190+
);
191+
} catch {
192+
return null;
193+
}
194+
}
195+
196+
private hasSkillsSnapshot(
197+
value: SessionIndexRecord,
198+
): value is SessionIndexRecord & { skillsSnapshot: unknown } {
199+
return "skillsSnapshot" in value;
200+
}
25201
}

apps/controller/src/services/openclaw-auth-service.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export class OpenClawAuthService {
187187
try {
188188
const profileKey = "openai-codex:default";
189189
const filePaths =
190-
await this.authProfilesStore.listAgentAuthProfilesPaths();
190+
await this.authProfilesStore.listExistingAuthProfilesPaths();
191191
for (const filePath of filePaths) {
192192
const profiles = await this.authProfilesStore.readAuthProfiles(
193193
filePath,
@@ -249,7 +249,7 @@ export class OpenClawAuthService {
249249

250250
try {
251251
const filePaths =
252-
await this.authProfilesStore.listAgentAuthProfilesPaths();
252+
await this.authProfilesStore.listExistingAuthProfilesPaths();
253253
if (filePaths.length === 0) return false;
254254
const profileKey = "openai-codex:default";
255255
await Promise.all(
@@ -488,10 +488,8 @@ export class OpenClawAuthService {
488488
key: string,
489489
profile: OAuthProfile,
490490
): Promise<void> {
491-
const filePaths = await this.authProfilesStore.listAgentAuthProfilesPaths();
492-
if (filePaths.length === 0) {
493-
throw new Error("No agent directory found for auth profiles");
494-
}
491+
const filePaths =
492+
await this.authProfilesStore.listWritableAuthProfilesPaths();
495493

496494
await Promise.all(
497495
filePaths.map(async (filePath) => {

0 commit comments

Comments
 (0)