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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -106,11 +110,20 @@ jobs:
NEXU_SKIP_RUNTIME_POSTINSTALL: "1"
run: pnpm install --frozen-lockfile

- name: Select test plan
id: plan
run: node scripts/ci/test-plan.mjs

- name: Test with coverage
if: github.event_name != 'pull_request'
run: pnpm test:coverage

- name: Run selected PR tests
if: github.event_name == 'pull_request'
run: ${{ steps.plan.outputs.test_command }}

- name: Upload unit coverage to Codecov
if: ${{ !cancelled() && hashFiles('coverage/lcov.info') != '' }}
if: ${{ github.event_name != 'pull_request' && !cancelled() && hashFiles('coverage/lcov.info') != '' }}
uses: codecov/codecov-action@v6
with:
use_oidc: true
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/desktop-ci-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -112,6 +114,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Select test plan
id: plan
run: node scripts/ci/test-plan.mjs

- name: Install tmux
if: matrix.os == 'macos'
shell: bash
Expand All @@ -131,7 +137,7 @@ jobs:
run: pnpm build

- name: Run unit tests (includes real launchd integration tests on macOS)
run: pnpm test
run: ${{ github.event_name == 'pull_request' && steps.plan.outputs.test_command || 'pnpm test:all' }}

- name: Launchd lifecycle e2e test
shell: bash
Expand Down
26 changes: 21 additions & 5 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ concurrency:
cancel-in-progress: true

jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
outputs:
run_desktop_e2e: ${{ steps.plan.outputs.run_desktop_e2e }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Select desktop e2e plan
id: plan
run: node scripts/ci/test-plan.mjs

validate-inputs:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
Expand All @@ -79,12 +94,13 @@ jobs:
# --------------------------------------------------------------------------
build:
needs: [validate-inputs]
if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.source == 'build') && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push')
needs: [plan, validate-inputs]
if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.source == 'build') && (github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && needs.plan.outputs.run_desktop_e2e == 'true'))
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 30
env:
E2E_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'model' }}
E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'pull_request' || github.event_name == 'push' }}
E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'push' }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -146,15 +162,15 @@ jobs:
# E2E test
# --------------------------------------------------------------------------
e2e:
needs: [validate-inputs, build]
if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped')
needs: [plan, validate-inputs, build]
if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') && (github.event_name != 'pull_request' || needs.plan.outputs.run_desktop_e2e == 'true')
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 30
env:
E2E_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'model' }}
E2E_SOURCE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source || (github.event_name == 'schedule' && 'download' || 'build') }}
E2E_CHANNEL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.channel || 'nightly' }}
E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'pull_request' || github.event_name == 'push' }}
E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'push' }}
defaults:
run:
working-directory: e2e/desktop
Expand Down
28 changes: 25 additions & 3 deletions apps/controller/src/services/skillhub/skill-dir-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class SkillDirWatcher {
private readonly db: SkillDb;
private readonly log: SkillDirWatcherLogFn;
private readonly debounceMs: number;
private readonly pollIntervalMs: number;
private readonly isSlugInFlight: (slug: string) => boolean;
private readonly userSkillsDir: string | null;
private readonly openclawStateDir: string | null;
Expand All @@ -27,12 +28,14 @@ export class SkillDirWatcher {
private workspaceWatcher: FSWatcher | null = null;
private workspaceSkillWatchers = new Map<string, FSWatcher>();
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;

constructor(opts: {
skillsDir: string;
skillDb: SkillDb;
log?: SkillDirWatcherLogFn;
debounceMs?: number;
pollIntervalMs?: number;
/** Returns true if the slug is currently being installed by the queue. */
isSlugInFlight?: (slug: string) => boolean;
/** User-level skills directory (~/.agents/skills/). */
Expand All @@ -48,6 +51,8 @@ export class SkillDirWatcher {
this.db = opts.skillDb;
this.log = opts.log ?? defaultLog;
this.debounceMs = opts.debounceMs ?? 500;
this.pollIntervalMs =
opts.pollIntervalMs ?? Math.max(this.debounceMs * 4, 1000);
this.isSlugInFlight = opts.isSlugInFlight ?? (() => false);
this.userSkillsDir = opts.userSkillsDir ?? null;
this.openclawStateDir = opts.openclawStateDir ?? null;
Expand Down Expand Up @@ -233,7 +238,10 @@ export class SkillDirWatcher {
return;
}

this.sharedWatcher = watch(this.skillsDir, { recursive: true }, () => {
// Only the first-level slug directories matter here. Non-recursive watching
// is more reliable for newly created skill directories than relying on
// recursive child propagation across platforms.
this.sharedWatcher = watch(this.skillsDir, () => {
this.scheduleSync();
});

Expand All @@ -247,7 +255,7 @@ export class SkillDirWatcher {
this.log("info", `Watching skills directory: ${this.skillsDir}`);

if (this.userSkillsDir && existsSync(this.userSkillsDir)) {
this.userWatcher = watch(this.userSkillsDir, { recursive: true }, () => {
this.userWatcher = watch(this.userSkillsDir, () => {
this.scheduleSync();
});

Expand Down Expand Up @@ -290,6 +298,15 @@ export class SkillDirWatcher {
`Watching workspace skill directories under: ${this.openclawStateDir}`,
);
}

// Some platforms occasionally miss new nested directory events from fs.watch.
// Keep a low-frequency reconciliation loop as a safety net so the ledger
// eventually reflects disk state even when no watcher callback fires.
this.pollTimer = setInterval(() => {
if (this.syncNow()) {
this.onChange();
}
}, this.pollIntervalMs);
}

private shouldProcessWorkspaceEvent(
Expand All @@ -309,6 +326,11 @@ export class SkillDirWatcher {
this.debounceTimer = null;
}

if (this.pollTimer !== null) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}

if (this.sharedWatcher !== null) {
this.sharedWatcher.close();
this.sharedWatcher = null;
Expand Down Expand Up @@ -368,7 +390,7 @@ export class SkillDirWatcher {

let watcher: FSWatcher;
try {
watcher = watch(wsSkillsDir, { recursive: true }, () => {
watcher = watch(wsSkillsDir, () => {
this.scheduleSync();
});
} catch (err) {
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"lint": "biome check .",
"lint:fix": "biome check --write --unsafe .",
"format": "biome check --write .",
"test": "vitest run",
"test": "pnpm test:core",
"test:all": "vitest run",
"test:core": "vitest run --config vitest.core.config.ts",
"test:extended": "vitest run --config vitest.extended.config.ts",
"test:coverage": "vitest run --coverage --coverage.reporter=lcov",
"generate-types": "pnpm --filter @nexu/controller generate-openapi && pnpm --filter @nexu/web generate-sdk",
"openclaw-runtime:install": "npm --prefix ./openclaw-runtime run install:cached",
Expand Down
Loading
Loading