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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# AI coding rules for NyxelOS

NyxelOS is an agentic OS (Bun/Hono/tRPC server, React/TanStack web, Drizzle
DB). These rules apply to every AI-assisted change in this repo.

## Commits
- Conventional Commits only (`feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`).
- Concise, meaningful messages — state the "why", not a change log.
- Small, focused commits. Don't bundle unrelated fixes.
- Don't commit generated noise or formatting-only diffs unless the task requires them.

## Code
- Minimal, typed, secure, maintainable. No AI slop, no filler, no speculative features.
- No unused abstractions or wrappers "for later." Three similar lines beat a premature abstraction.
- Follow existing repo architecture and naming — no unrelated refactors.
- Never expose secrets (API keys, tokens, OAuth state) to browser/client responses.
- Avoid unsafe tool execution paths (unsandboxed eval, unchecked shell/file access) — match existing permission/approval patterns (`tools-builtin/*`, `approvals.ts`).
- Keep agent runtime behavior deterministic where possible (explicit status transitions, atomic claims over check-then-write).

## UI
- Do not change UI/UX unless the task requires it. No decorative or visual-redesign work.
- Preserve shadcn/default-theme conventions when UI changes are required.

## Validation
- Don't run the full test suite by default. Run targeted tests for the files/packages you touched.
- Typecheck only the changed package/app when feasible; lint only the touched area.
- Only run broad/global suites when a change touches shared core behavior and no smaller validation exists.
25 changes: 24 additions & 1 deletion apps/server/src/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import type { DbRepository } from "@nyxel/db";
import { getDb } from "@nyxel/db";
import { createTestUser, installTestDb } from "@nyxel/db/test-utils";
import { checkStaleAgentRuns } from "./scheduler";
import { checkStaleAgentRuns, runAutomation } from "./scheduler";

async function seedWorkspace(db: DbRepository, path: string) {
const user = createTestUser(path);
Expand Down Expand Up @@ -123,3 +123,26 @@ describe("checkStaleAgentRuns", () => {
expect(stillCompleted?.status).toBe("completed");
});
});

describe("runAutomation", () => {
it("skips a concurrent dispatch of the same automation instead of running it twice", async () => {
const db = getDb();
const { workspace } = await seedWorkspace(db, ctx.path);
const automation = await db.createAutomation({
workspaceId: workspace.id,
agentId: null,
name: "No agent configured",
triggerType: "cron",
cronExpression: "* * * * *",
});

const [first, second] = await Promise.all([
runAutomation(automation),
runAutomation(automation),
]);

const outputs = [first.output, second.output];
expect(outputs).toContain("Skipped — a run for this automation is already in progress.");
expect(outputs.filter((o) => o.includes("Agent missing"))).toHaveLength(1);
});
});
40 changes: 30 additions & 10 deletions apps/server/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ async function runWorkflowAutomation(
return { taskId: "", runId, output: outputText };
}

/** Guards against the same automation dispatching twice concurrently — e.g.
* a run that outlives one POLL_INTERVAL_MS tick is still "due" by
* `nextRunAt` (only advanced once `finishAutomationRun` completes) on the
* next tick, and `automations.runNow` could otherwise race a poll-triggered
* run of the same automation. Process-local only (mirrors knowledge-base.ts's
* `inFlightSyncs`), not a cross-replica lock. */
const automationsInFlight = new Set<string>();

/** Exported for automations.runNow — lets a user trigger a run immediately
* without waiting for the schedule, e.g. to test a new automation. Dispatches
* on targetKind: "agent" (default, existing behavior) runs the automation's
Expand All @@ -208,16 +216,28 @@ async function runWorkflowAutomation(
export async function runAutomation(
automation: AutomationRecord,
): Promise<{ taskId: string; runId: string; output: string }> {
await emitNyxelEvent({
workspaceId: automation.workspaceId,
type: NyxelEvent.AutomationTriggered,
entityType: "automation",
entityId: automation.id,
payload: { triggerType: automation.triggerType, targetKind: automation.targetKind },
});
return automation.targetKind === "workflow"
? runWorkflowAutomation(automation)
: runAgentAutomation(automation);
if (automationsInFlight.has(automation.id)) {
return {
taskId: "",
runId: "",
output: "Skipped — a run for this automation is already in progress.",
};
}
automationsInFlight.add(automation.id);
try {
await emitNyxelEvent({
workspaceId: automation.workspaceId,
type: NyxelEvent.AutomationTriggered,
entityType: "automation",
entityId: automation.id,
payload: { triggerType: automation.triggerType, targetKind: automation.targetKind },
});
return automation.targetKind === "workflow"
? await runWorkflowAutomation(automation)
: await runAgentAutomation(automation);
} finally {
automationsInFlight.delete(automation.id);
}
}

function resolveWatchPath(watchPath: string): string {
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/tools-builtin/read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "bun:test";
import type { ToolRecord } from "@nyxel/db";
import { createSkillContext } from "@nyxel/skills-sdk";
import { buildTerminalOutputTool } from "./read";
import { buildTerminalRunTool, getTerminalSession } from "./terminal";

function record(overrides: Partial<ToolRecord> = {}): ToolRecord {
return {
id: "tool-1",
workspaceId: "workspace-1",
name: "Terminal output",
description: "Read buffered terminal output.",
kind: "terminal_output",
config: {},
sensitive: false,
enabled: true,
builtin: true,
createdAt: new Date(),
...overrides,
};
}

describe("buildTerminalOutputTool output cap", () => {
it("caps output to the same tail-slice budget terminal_run/task_run already apply", async () => {
const runTool = buildTerminalRunTool(record({ kind: "terminal_run" }));
const { execId } = await runTool.run(
// Each line is 100 chars + newline; 200 lines comfortably exceeds the 8000-char cap.
{ command: `for i in $(seq 1 200); do printf '%0100d\\n' "$i"; done` },
createSkillContext(runTool.permissions),
);

await Bun.sleep(500);
const session = getTerminalSession(execId);
if (!session) throw new Error("session not found");
expect(session.output.length).toBeGreaterThan(8000);

const outputTool = buildTerminalOutputTool(record());
const result = await outputTool.run({ execId }, createSkillContext(outputTool.permissions));

expect(result.output.length).toBeLessThanOrEqual(8000);
expect(result.output).toBe(session.output.slice(-8000));
});
});
Loading
Loading