Compare commits
12 Commits
69f45867b2
...
4d8f403d78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d8f403d78 | ||
|
|
f742a1d765 | ||
|
|
b906cb2377 | ||
|
|
79a6f77659 | ||
|
|
5eab058304 | ||
|
|
b8321cbb00 | ||
|
|
79d494ec79 | ||
|
|
5058489c2f | ||
|
|
72102e7fdb | ||
|
|
4df5e6dec7 | ||
|
|
4fb8594987 | ||
|
|
cc7cdee60b |
61
README.md
61
README.md
@ -21,12 +21,15 @@ pnpm dev
|
|||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
| Package | Role |
|
| Package | Role |
|
||||||
| -------------- | ------------------------------------------------------------------------ |
|
| ------------------ | ------------------------------------------------------------------------ |
|
||||||
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
||||||
| `gadget-drone` | Worker process — runs the agentic workflow loop in workspace directories |
|
| `gadget-drone` | Worker process — runs the agentic workflow loop in workspace directories |
|
||||||
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI |
|
| `gadget-tasks` | Scheduled task worker — headless IDE client for cron-driven tasks |
|
||||||
| `@gadget/api` | Shared TypeScript interfaces — common types across all packages |
|
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI |
|
||||||
|
| `@gadget/ai-toolbox` | Shared AI tool implementations — search, file, plan, subagent tools |
|
||||||
|
| `@gadget/api` | Shared TypeScript interfaces — common types across all packages |
|
||||||
|
| `@gadget/config` | Shared YAML config loader — per-package configuration |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@ -50,6 +53,30 @@ pnpm dev
|
|||||||
6. **Streaming response** flows back: thinking → response → tool calls
|
6. **Streaming response** flows back: thinking → response → tool calls
|
||||||
7. **Results persist** in MongoDB as ChatTurn records
|
7. **Results persist** in MongoDB as ChatTurn records
|
||||||
|
|
||||||
|
### Scheduled Tasks (gadget-tasks)
|
||||||
|
|
||||||
|
gadget-tasks is a **headless IDE client** that automates the browser IDE flow on a cron schedule. It does not duplicate AI, database, or workspace code — it drives the existing gadget-code platform via REST API and Socket.IO, the same protocol the browser IDE uses.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ gadget-tasks │────▶│ gadget-code │────▶│ gadget-drone │
|
||||||
|
│ (Scheduler) │◀────│ (Express 5) │◀────│ (Worker) │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
│ │ │
|
||||||
|
│ REST + Socket.IO │ MongoDB │ Files
|
||||||
|
│ JWT Auth │ Redis │ Git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-task flow:**
|
||||||
|
1. CronJob fires → create ChatSession via REST API
|
||||||
|
2. Lock drone via Socket.IO `requestSessionLock`
|
||||||
|
3. Set workspace mode to Agent via `requestWorkspaceMode`
|
||||||
|
4. Submit task prompt via `submitPrompt` → drone processes work order
|
||||||
|
5. Receive `workOrderComplete` → release session lock
|
||||||
|
6. Update `task.lastRun` via `PATCH /projects/:id/tasks/:taskId/lastRun`
|
||||||
|
|
||||||
|
See [gadget-tasks documentation](./docs/gadget-tasks.md) for setup and usage.
|
||||||
|
|
||||||
## AI Provider Setup
|
## AI Provider Setup
|
||||||
|
|
||||||
Before using AI features, add a provider via CLI:
|
Before using AI features, add a provider via CLI:
|
||||||
@ -120,6 +147,21 @@ pnpm dev
|
|||||||
# Drone worker (in a workspace directory)
|
# Drone worker (in a workspace directory)
|
||||||
cd ~/my-gadget-workspace
|
cd ~/my-gadget-workspace
|
||||||
pnpm --filter gadget-drone dev
|
pnpm --filter gadget-drone dev
|
||||||
|
|
||||||
|
# Task scheduler (requires running gadget-code + drone)
|
||||||
|
cd gadget-tasks
|
||||||
|
pnpm dev -- --user=admin@example.com --password=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Install (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Link gadget-drone and gadget-tasks globally for CLI use
|
||||||
|
pnpm link:global
|
||||||
|
|
||||||
|
# Now available as system commands
|
||||||
|
gadget-drone
|
||||||
|
gadget-tasks --user=admin@example.com --password=secret
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@ -183,13 +225,16 @@ See [`packages/ai/README.md`](./packages/ai/README.md) for the full API referenc
|
|||||||
- [Workspace Management](./docs/gadget-workspace.md)
|
- [Workspace Management](./docs/gadget-workspace.md)
|
||||||
- [Drone Documentation](./gadget-drone/docs/gadget-drone.md)
|
- [Drone Documentation](./gadget-drone/docs/gadget-drone.md)
|
||||||
- [UI Design Guide](./gadget-code/docs/ui-design-guide.md)
|
- [UI Design Guide](./gadget-code/docs/ui-design-guide.md)
|
||||||
|
- [gadget-tasks Documentation](./docs/gadget-tasks.md)
|
||||||
|
|
||||||
## Monorepo Structure
|
## Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
gadget/
|
gadget/
|
||||||
├── packages/ai/ # @gadget/ai — AI API abstraction
|
├── packages/ai/ # @gadget/ai — AI API abstraction
|
||||||
|
├── packages/ai-toolbox/ # @gadget/ai-toolbox — Shared tool implementations
|
||||||
├── packages/api/ # @gadget/api — Shared interfaces
|
├── packages/api/ # @gadget/api — Shared interfaces
|
||||||
|
├── packages/config/ # @gadget/config — YAML config loader
|
||||||
├── gadget-code/ # Web service + browser IDE
|
├── gadget-code/ # Web service + browser IDE
|
||||||
│ ├── src/ # Backend (Express, Socket.IO, Mongoose)
|
│ ├── src/ # Backend (Express, Socket.IO, Mongoose)
|
||||||
│ ├── frontend/ # Frontend (React, Vite, Tailwind)
|
│ ├── frontend/ # Frontend (React, Vite, Tailwind)
|
||||||
@ -197,6 +242,8 @@ gadget/
|
|||||||
├── gadget-drone/ # Worker process
|
├── gadget-drone/ # Worker process
|
||||||
│ ├── src/ # Drone implementation
|
│ ├── src/ # Drone implementation
|
||||||
│ └── docs/ # Drone documentation
|
│ └── docs/ # Drone documentation
|
||||||
|
├── gadget-tasks/ # Scheduled task worker
|
||||||
|
│ └── src/ # Headless IDE client + cron scheduler
|
||||||
└── docs/ # Architecture & protocol docs
|
└── docs/ # Architecture & protocol docs
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -207,4 +254,4 @@ Apache 2.0 — See [LICENSE](./LICENSE) for details.
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Status:** Production-ready foundation with complete Chat Session UI
|
**Status:** Production-ready foundation with complete Chat Session UI
|
||||||
**Last Updated:** April 29, 2026
|
**Last Updated:** May 17, 2026
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Agent Toolbox is a foundational component of the Gadget platform that enables AI agents to perform real-world actions through a standardized, extensible tool system. Tools are functions that agents can invoke to interact with external services, manipulate files, search the web, and execute various operations.
|
The Agent Toolbox (`@gadget/ai-toolbox`) is a shared package providing AI agent tool implementations for the Gadget platform. It was extracted from `@gadget/ai` into its own package so that agent tools can be maintained independently of the AI provider abstraction layer.
|
||||||
|
|
||||||
## Architecture
|
**Consumers:** `gadget-drone` (the only current consumer). gadget-tasks does **not** use `@gadget/ai-toolbox` — it drives the gadget-code platform via REST API and Socket.IO as a headless IDE client, delegating all AI and tool execution to drones.
|
||||||
|
|
||||||
### Design Philosophy
|
### Design Philosophy
|
||||||
|
|
||||||
@ -15,13 +15,14 @@ The toolbox system is built on these core principles:
|
|||||||
3. **Extensibility**: Easy to add new tools without modifying core infrastructure
|
3. **Extensibility**: Easy to add new tools without modifying core infrastructure
|
||||||
4. **Security**: Tools require explicit credentials configured in the environment
|
4. **Security**: Tools require explicit credentials configured in the environment
|
||||||
5. **Error Handling**: Comprehensive error reporting with recovery hints
|
5. **Error Handling**: Comprehensive error reporting with recovery hints
|
||||||
|
6. **Shared Implementation**: One tool codebase used by all agent processes
|
||||||
|
|
||||||
### Component Overview
|
### Component Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ AiToolbox │ ← Manages tool registration and lookup
|
│ AiToolbox │ ← Manages tool registration and lookup
|
||||||
│ - env │ ← Holds IAiEnvironment with credentials
|
│ - env │ ← Holds GadgetToolboxEnvironment with credentials
|
||||||
│ - tools │ ← Map of all registered tools
|
│ - tools │ ← Map of all registered tools
|
||||||
│ - modeSets │ ← Tools organized by ChatSessionMode
|
│ - modeSets │ ← Tools organized by ChatSessionMode
|
||||||
└────────┬────────┘
|
└────────┬────────┘
|
||||||
@ -29,7 +30,7 @@ The toolbox system is built on these core principles:
|
|||||||
│ register()
|
│ register()
|
||||||
▼
|
▼
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ AiTool │ ← Abstract base class for all tools
|
│ GadgetTool │ ← Abstract base class for all tools
|
||||||
│ - name │ ← Unique tool identifier
|
│ - name │ ← Unique tool identifier
|
||||||
│ - category │ ← Tool category (search, file, etc.)
|
│ - category │ ← Tool category (search, file, etc.)
|
||||||
│ - definition │ ← JSON Schema for AI provider
|
│ - definition │ ← JSON Schema for AI provider
|
||||||
@ -41,19 +42,21 @@ The toolbox system is built on these core principles:
|
|||||||
▼
|
▼
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ GoogleSearch │ ← Example tool implementation
|
│ GoogleSearch │ ← Example tool implementation
|
||||||
│ GrepTool │ ← Future tools...
|
│ FileReadTool │ ← Read files from the workspace
|
||||||
|
│ ShellTool │ ← Execute shell commands
|
||||||
|
│ SubagentTool │ ← Spawn subagents for tasks
|
||||||
│ ... │
|
│ ... │
|
||||||
└─────────────────┘
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core Interfaces
|
## Core Interfaces
|
||||||
|
|
||||||
### IAiEnvironment
|
### GadgetToolboxEnvironment
|
||||||
|
|
||||||
The `IAiEnvironment` interface carries configuration and credentials from the application layer (which has access to YAML configs) down to the `@gadget/ai` package (which cannot read configs directly).
|
The `GadgetToolboxEnvironment` interface carries configuration and credentials from the application layer (which has access to YAML configs) down to the `@gadget/ai-toolbox` package (which cannot read configs directly). It also carries workspace and project context that tools need.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface IAiEnvironment {
|
export interface GadgetToolboxEnvironment {
|
||||||
NODE_ENV: string;
|
NODE_ENV: string;
|
||||||
services?: {
|
services?: {
|
||||||
google?: {
|
google?: {
|
||||||
@ -62,24 +65,24 @@ export interface IAiEnvironment {
|
|||||||
engineId?: string;
|
engineId?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
github?: {
|
|
||||||
token?: string;
|
|
||||||
};
|
|
||||||
slack?: {
|
|
||||||
token?: string;
|
|
||||||
signingSecret?: string;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
};
|
||||||
|
workspace?: {
|
||||||
|
workspaceDir?: string;
|
||||||
|
projectDir?: string;
|
||||||
|
cacheDir?: string;
|
||||||
|
};
|
||||||
|
project?: IProject;
|
||||||
|
chatSessionMode?: ChatSessionMode;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Design Decisions:**
|
**Key Design Decisions:**
|
||||||
|
|
||||||
- **Services Object**: Credentials are organized by service (google, github, slack, etc.)
|
- **Services Object**: Credentials are organized by service (google, etc.)
|
||||||
- **Optional Everything**: All fields are optional to support partial configurations
|
- **Optional Everything**: All fields are optional to support partial configurations
|
||||||
- **Extensible**: The `[key: string]: unknown` index signature allows future services without breaking changes
|
- **Workspace Context**: Tools that work with files need `workspaceDir`, `projectDir`, and `cacheDir`
|
||||||
- **Type Safety**: Known services have typed interfaces
|
- **Project Context**: Tools can access the current `IProject` and `ChatSessionMode`
|
||||||
|
- **Extensible**: Future services can be added without breaking changes
|
||||||
|
|
||||||
### AiToolbox
|
### AiToolbox
|
||||||
|
|
||||||
@ -87,19 +90,28 @@ The toolbox manages tool registration, organization, and retrieval:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export class AiToolbox {
|
export class AiToolbox {
|
||||||
constructor(env: IAiEnvironment);
|
constructor(env: GadgetToolboxEnvironment);
|
||||||
|
|
||||||
// Register a tool for use by agents
|
// Register a tool for use by agents
|
||||||
register(tool: AiTool, modes?: string[]): void;
|
register(tool: IAiTool, modes?: string[]): void;
|
||||||
|
|
||||||
// Get a tool by name (for system tools)
|
// Get a tool by name (for system tools)
|
||||||
getTool(name: string): AiTool | undefined;
|
getTool(name: string): IAiTool | undefined;
|
||||||
|
|
||||||
// Get all tools for a specific mode
|
// Get all tools for a specific mode
|
||||||
getModeSet(mode: string): ToolSet | undefined;
|
getModeSet(mode: string): ToolSet | undefined;
|
||||||
|
|
||||||
|
// Get tool names for a specific mode
|
||||||
|
getToolNamesForMode(mode: string): string[];
|
||||||
|
|
||||||
|
// Update workspace context (called when project changes)
|
||||||
|
updateWorkspace(workspace: { workspaceDir?: string; projectDir?: string; cacheDir?: string }): void;
|
||||||
|
|
||||||
|
// Update project context (called when session mode changes)
|
||||||
|
updateProjectContext(project: IProject, mode: ChatSessionMode): void;
|
||||||
|
|
||||||
// Access environment credentials
|
// Access environment credentials
|
||||||
get env(): IAiEnvironment;
|
get env(): GadgetToolboxEnvironment;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -108,14 +120,18 @@ export class AiToolbox {
|
|||||||
- **System Tools** (no modes): Called by the platform itself (e.g., auto-naming chat sessions)
|
- **System Tools** (no modes): Called by the platform itself (e.g., auto-naming chat sessions)
|
||||||
- **Agent Tools** (with modes): Available to AI agents in specific ChatSessionModes (e.g., "code", "research", "debug")
|
- **Agent Tools** (with modes): Available to AI agents in specific ChatSessionModes (e.g., "code", "research", "debug")
|
||||||
|
|
||||||
### AiTool
|
### GadgetTool
|
||||||
|
|
||||||
All tools extend this abstract base class:
|
All tools extend this abstract base class:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export abstract class AiTool {
|
export abstract class GadgetTool implements IAiTool {
|
||||||
protected _toolbox: AiToolbox;
|
protected _toolbox: AiToolbox;
|
||||||
|
|
||||||
|
constructor(toolbox: AiToolbox);
|
||||||
|
|
||||||
|
get toolbox(): AiToolbox;
|
||||||
|
|
||||||
// Unique identifier for the tool
|
// Unique identifier for the tool
|
||||||
abstract get name(): string;
|
abstract get name(): string;
|
||||||
|
|
||||||
@ -155,9 +171,9 @@ The tool execution flow demonstrates the abstraction layer between the common to
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Application Layer (gadget-drone / gadget-code) │
|
│ Application Layer (gadget-drone) │
|
||||||
│ - Reads YAML config │
|
│ - Reads YAML config │
|
||||||
│ - Constructs IAiEnvironment │
|
│ - Constructs GadgetToolboxEnvironment │
|
||||||
│ - Creates AiToolbox │
|
│ - Creates AiToolbox │
|
||||||
│ - Registers tools │
|
│ - Registers tools │
|
||||||
└────────────────────┬────────────────────────────────────────┘
|
└────────────────────┬────────────────────────────────────────┘
|
||||||
@ -293,16 +309,6 @@ This allows complex multi-step operations where the AI can:
|
|||||||
|
|
||||||
Tools requiring credentials need them configured in your YAML config files.
|
Tools requiring credentials need them configured in your YAML config files.
|
||||||
|
|
||||||
#### gadget-code.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Add to gadget-code.yaml
|
|
||||||
google:
|
|
||||||
cse:
|
|
||||||
apiKey: "${GOOGLE_CSE_API_KEY}"
|
|
||||||
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### gadget-drone.yaml
|
#### gadget-drone.yaml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@ -313,6 +319,8 @@ google:
|
|||||||
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** `gadget-code` and `gadget-tasks` do not need toolbox credentials. gadget-code builds prompts but doesn't execute tools directly. gadget-tasks is a headless IDE client that delegates all tool execution to drones via the platform.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Set the actual values in your shell or deployment environment:
|
Set the actual values in your shell or deployment environment:
|
||||||
@ -324,14 +332,14 @@ export GOOGLE_CSE_ENGINE_ID="your-engine-id-here"
|
|||||||
|
|
||||||
### TypeScript Usage
|
### TypeScript Usage
|
||||||
|
|
||||||
In consumer applications, construct the environment and pass to the AI API:
|
In consumer applications, construct the environment and pass to the toolbox:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// gadget-drone/src/services/ai.ts
|
// gadget-drone/src/services/ai.ts
|
||||||
import { createAiApi, IAiEnvironment } from "@gadget/ai";
|
import { AiToolbox, GadgetToolboxEnvironment } from "@gadget/ai-toolbox";
|
||||||
import env from "../config/env.js";
|
import env from "../config/env.js";
|
||||||
|
|
||||||
const aiEnv: IAiEnvironment = {
|
const toolboxEnv: GadgetToolboxEnvironment = {
|
||||||
NODE_ENV: env.NODE_ENV,
|
NODE_ENV: env.NODE_ENV,
|
||||||
services: {
|
services: {
|
||||||
google: {
|
google: {
|
||||||
@ -343,7 +351,12 @@ const aiEnv: IAiEnvironment = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const api = createAiApi(aiEnv, providerConfig, logger);
|
const toolbox = new AiToolbox(toolboxEnv);
|
||||||
|
|
||||||
|
// Register tools for specific chat session modes
|
||||||
|
toolbox.register(new GoogleSearchTool(toolbox), ["code", "research"]);
|
||||||
|
toolbox.register(new FileReadTool(toolbox), ["code", "plan", "build"]);
|
||||||
|
toolbox.register(new SubagentTool(toolbox), ["code"]);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Tool: Google Search
|
## Example Tool: Google Search
|
||||||
@ -450,7 +463,7 @@ Tool Call: search_google({
|
|||||||
|
|
||||||
### Implementation Details
|
### Implementation Details
|
||||||
|
|
||||||
**File**: `packages/ai/src/tools/search/google.ts`
|
**File**: `packages/ai-toolbox/src/network/search-google.ts`
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
|
|
||||||
@ -560,14 +573,19 @@ RECOVERY HINT: Provide a 'query' parameter with your search terms and try again.
|
|||||||
|
|
||||||
### Step 1: Create Tool Class
|
### Step 1: Create Tool Class
|
||||||
|
|
||||||
Extend `AiTool` and implement the required methods:
|
Extend `GadgetTool` and implement the required methods:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { AiTool, IToolArguments, IToolDefinition } from "../tool.js";
|
import { GadgetTool } from "@gadget/ai-toolbox";
|
||||||
import { IAiLogger } from "../../api.js";
|
import type { AiToolbox } from "@gadget/ai-toolbox";
|
||||||
import { formatError } from "../tool-error.js";
|
import type { IToolArguments, IToolDefinition, IAiLogger } from "@gadget/ai";
|
||||||
|
import { formatError } from "@gadget/ai";
|
||||||
|
|
||||||
|
export class MyNewTool extends GadgetTool {
|
||||||
|
constructor(toolbox: AiToolbox) {
|
||||||
|
super(toolbox);
|
||||||
|
}
|
||||||
|
|
||||||
export class MyNewTool extends AiTool {
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "my_tool_name";
|
return "my_tool_name";
|
||||||
}
|
}
|
||||||
@ -616,11 +634,14 @@ export class MyNewTool extends AiTool {
|
|||||||
throw new Error("API key not configured in environment");
|
throw new Error("API key not configured in environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Access workspace context
|
||||||
|
const projectDir = this.toolbox.env.workspace?.projectDir;
|
||||||
|
|
||||||
// Perform the operation
|
// Perform the operation
|
||||||
logger.debug("executing my tool", { args });
|
logger.debug("executing my tool", { args });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.doSomething(args.param1);
|
const result = await this.doSomething(args.param1 as string);
|
||||||
return `Operation successful: ${result}`;
|
return `Operation successful: ${result}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return formatError({
|
return formatError({
|
||||||
@ -639,10 +660,10 @@ export class MyNewTool extends AiTool {
|
|||||||
|
|
||||||
### Step 2: Add Credentials (if needed)
|
### Step 2: Add Credentials (if needed)
|
||||||
|
|
||||||
Update `IAiEnvironment` in `packages/ai/src/config/env.ts`:
|
Update `GadgetToolboxEnvironment` in `packages/ai-toolbox/src/toolbox.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface IAiEnvironment {
|
export interface GadgetToolboxEnvironment {
|
||||||
NODE_ENV: string;
|
NODE_ENV: string;
|
||||||
services?: {
|
services?: {
|
||||||
google?: {
|
google?: {
|
||||||
@ -655,40 +676,34 @@ export interface IAiEnvironment {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
};
|
};
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
};
|
||||||
|
workspace?: {
|
||||||
|
workspaceDir?: string;
|
||||||
|
projectDir?: string;
|
||||||
|
cacheDir?: string;
|
||||||
|
};
|
||||||
|
project?: IProject;
|
||||||
|
chatSessionMode?: ChatSessionMode;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Update config types in `packages/config/src/types.ts`:
|
Update config types in `packages/config/src/types.ts` and consumer config readers to populate the environment.
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface GadgetDroneConfig {
|
|
||||||
// ... existing fields
|
|
||||||
myService?: {
|
|
||||||
apiKey: string;
|
|
||||||
endpoint: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Update consumer config readers to populate the environment.
|
|
||||||
|
|
||||||
### Step 3: Register the Tool
|
### Step 3: Register the Tool
|
||||||
|
|
||||||
In gadget-drone startup code (to be implemented):
|
In gadget-drone startup code:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { AiToolbox } from "@gadget/ai";
|
import { AiToolbox } from "@gadget/ai-toolbox";
|
||||||
import { MyNewTool } from "@gadget/ai/tools/my-tool.js";
|
import { MyNewTool } from "@gadget/ai-toolbox";
|
||||||
|
|
||||||
const toolbox = new AiToolbox(aiEnv);
|
const toolbox = new AiToolbox(toolboxEnv);
|
||||||
|
|
||||||
// Register as system tool (no modes)
|
// Register as system tool (no modes — called by the platform itself)
|
||||||
toolbox.register(new MyNewTool());
|
toolbox.register(new MyNewTool(toolbox));
|
||||||
|
|
||||||
// Or register for specific modes
|
// Or register for specific chat session modes
|
||||||
toolbox.register(new MyNewTool(), ["code", "debug"]);
|
toolbox.register(new MyNewTool(toolbox), ["code", "debug"]);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Tools
|
## Testing Tools
|
||||||
@ -743,7 +758,7 @@ import { AiToolbox } from "../src/toolbox.js";
|
|||||||
|
|
||||||
describe("GoogleSearchTool integration", () => {
|
describe("GoogleSearchTool integration", () => {
|
||||||
it("should call Google CSE API with correct parameters", async () => {
|
it("should call Google CSE API with correct parameters", async () => {
|
||||||
const env = {
|
const env: GadgetToolboxEnvironment = {
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
services: {
|
services: {
|
||||||
google: {
|
google: {
|
||||||
@ -853,8 +868,21 @@ Planned improvements to the toolbox system:
|
|||||||
4. **Tool Metadata**: Version, author, usage statistics
|
4. **Tool Metadata**: Version, author, usage statistics
|
||||||
5. **Result Streaming**: Stream large results back to AI
|
5. **Result Streaming**: Stream large results back to AI
|
||||||
|
|
||||||
|
## Tool Categories
|
||||||
|
|
||||||
|
The `@gadget/ai-toolbox` package organizes tools into the following categories:
|
||||||
|
|
||||||
|
| Category | Directory | Tools | Description |
|
||||||
|
|----------|-----------|-------|-------------|
|
||||||
|
| Network | `src/network/` | `GoogleSearchTool`, `FetchUrlTool`, `WebFetcherTool` | Web search and URL fetching |
|
||||||
|
| System | `src/system/` | `FileReadTool`, `FileWriteTool`, `FileEditTool`, `FileListTool`, `GlobTool`, `GrepTool`, `ShellTool` | File system and shell operations |
|
||||||
|
| Plan | `src/plan/` | `PlanFileReadTool`, `PlanFileWriteTool`, `PlanFileEditTool`, `PlanFileListTool` | Plan-mode file operations |
|
||||||
|
| Chat | `src/chat/` | `SubagentTool` | Subagent spawning for multi-agent workflows |
|
||||||
|
| Project | `src/project/` | `ListSkillsTool`, `ReadSkillTool` | Project skill access |
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Configuration Guide](./configuration.md) - Setting up tool credentials
|
- [Configuration Guide](./configuration.md) - Setting up tool credentials
|
||||||
- [Architecture Overview](./architecture.md) - System architecture
|
- [Architecture Overview](./architecture.md) - System architecture
|
||||||
- [AI API Reference](../packages/ai/README.md) - `@gadget/ai` package documentation
|
- [AI API Reference](../packages/ai/README.md) - `@gadget/ai` package documentation
|
||||||
|
- [gadget-tasks Documentation](./gadget-tasks.md) - Scheduled task worker (does NOT use @gadget/ai-toolbox)
|
||||||
|
|||||||
220
docs/gadget-tasks.md
Normal file
220
docs/gadget-tasks.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# gadget-tasks Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
gadget-tasks is the Gadget Code **scheduled task worker** — a headless IDE client that automates the browser IDE flow on a cron schedule. It drives the gadget-code platform via REST API and Socket.IO, using the exact same protocol the browser IDE uses, but without any UI.
|
||||||
|
|
||||||
|
**Key design principle:** gadget-tasks contains **zero duplicated code**. No Mongoose models, no AI API calls, no prompt templates, no workspace management. All of that flows through the gadget-code platform, which delegates to drones for execution.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ gadget-tasks │────▶│ gadget-code │────▶│ gadget-drone │
|
||||||
|
│ (Headless IDE) │◀────│ (Platform) │◀────│ (AWL Worker) │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
│ │ │
|
||||||
|
│ REST API │ MongoDB │ Files
|
||||||
|
│ Socket.IO │ Redis │ Git
|
||||||
|
│ JWT Auth │ Socket.IO relay │ AI API
|
||||||
|
```
|
||||||
|
|
||||||
|
gadget-tasks acts as a **programmatic IDE user**:
|
||||||
|
- Authenticates with the platform (same as logging into the browser IDE)
|
||||||
|
- Selects a drone (same as clicking a drone in the UI)
|
||||||
|
- Creates chat sessions, locks drones, submits prompts (same as typing in the chat)
|
||||||
|
- Waits for work order completion (same as watching the streaming response)
|
||||||
|
- Updates task records when done
|
||||||
|
|
||||||
|
## Per-Task Execution Flow
|
||||||
|
|
||||||
|
When a CronJob fires for a scheduled task:
|
||||||
|
|
||||||
|
1. **Create ChatSession** — `POST /api/v1/chat-sessions`
|
||||||
|
2. **Lock drone** — Socket.IO `requestSessionLock(drone, project, session)`
|
||||||
|
3. **Set workspace mode** — Socket.IO `requestWorkspaceMode(drone, project, session, "agent")`
|
||||||
|
4. **Submit prompt** — Socket.IO `submitPrompt(task.content)` → creates ChatTurn with canonical system prompt → routes work order to drone
|
||||||
|
5. **Wait for completion** — Socket.IO receives `workOrderComplete(turnId, success, message)`
|
||||||
|
6. **Release drone lock** — Socket.IO `releaseSessionLock(drone, project, session)`
|
||||||
|
7. **Update task.lastRun** — `PATCH /api/v1/projects/:id/tasks/:taskId/lastRun`
|
||||||
|
|
||||||
|
Steps 2–6 use the **same Socket.IO protocol** the browser IDE uses. gadget-code builds the system prompt from its canonical templates, routes the work order to the drone, and persists all results as ChatTurn records — identical to interactive use.
|
||||||
|
|
||||||
|
## Source Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gadget-tasks/
|
||||||
|
├── gadget-tasks.yaml # YAML configuration
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── src/
|
||||||
|
├── gadget-tasks.ts # Main entry — startup, shutdown, drone selection
|
||||||
|
├── config/
|
||||||
|
│ └── env.ts # Config loader — platform.baseUrl, redis, concurrency
|
||||||
|
├── lib/
|
||||||
|
│ ├── process.ts # GadgetProcess base class
|
||||||
|
│ └── service.ts # GadgetService base class
|
||||||
|
└── services/
|
||||||
|
├── platform.ts # REST API + Socket.IO headless IDE client
|
||||||
|
├── scheduler.ts # CronJob management, concurrency control
|
||||||
|
└── lock.ts # Redis singleton lock (prevents duplicate instances)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Services
|
||||||
|
|
||||||
|
| Service | File | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| **PlatformService** | `src/services/platform.ts` | REST API client (auth, projects, sessions, drones) + Socket.IO client (session lock, workspace mode, prompt submission, work order tracking) |
|
||||||
|
| **SchedulerService** | `src/services/scheduler.ts` | Creates CronJobs from task crontab expressions, enforces concurrency limit, delegates to PlatformService |
|
||||||
|
| **TaskLockService** | `src/services/lock.ts` | Redis-based singleton lock — prevents multiple gadget-tasks instances from running simultaneously |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### gadget-tasks.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
timezone: America/New_York
|
||||||
|
platform:
|
||||||
|
baseUrl: https://code-dev.g4dge7.com:5174
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
# password: optional
|
||||||
|
# keyPrefix: defaults to "gadget:"
|
||||||
|
concurrency: 1
|
||||||
|
logging:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
|
file:
|
||||||
|
enabled: true
|
||||||
|
path: ~/logs/gadget-tasks
|
||||||
|
# name: defaults to "gadget-tasks"
|
||||||
|
# maxWritesPerFile: defaults to 10000
|
||||||
|
# maxFiles: defaults to 10
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `timezone` | No | `America/New_York` | Timezone for CronJob scheduling |
|
||||||
|
| `platform.baseUrl` | **Yes** | — | URL of the gadget-code platform |
|
||||||
|
| `redis.host` | No | `localhost` | Redis host for singleton lock |
|
||||||
|
| `redis.port` | No | `6379` | Redis port |
|
||||||
|
| `redis.password` | No | — | Redis password |
|
||||||
|
| `redis.keyPrefix` | No | `gadget:` | Redis key prefix |
|
||||||
|
| `concurrency` | No | `1` | Max concurrent tasks (sequential by default) |
|
||||||
|
| `logging.console.enabled` | No | `false` | Enable console logging |
|
||||||
|
| `logging.file.enabled` | No | `false` | Enable file logging |
|
||||||
|
| `logging.file.path` | No | `./logs` | Log file directory |
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
Email and password are **not stored in the config file**. They are provided at startup:
|
||||||
|
|
||||||
|
- **CLI args:** `--user=admin@example.com --password=secret`
|
||||||
|
- **Interactive prompt:** If no CLI args, you'll be prompted (same pattern as gadget-drone)
|
||||||
|
|
||||||
|
This avoids storing credentials in config files that may be checked into version control.
|
||||||
|
|
||||||
|
## Startup Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. hookProcessSignals() — SIGINT handler
|
||||||
|
2. Acquire Redis singleton lock — exits if another instance is running
|
||||||
|
3. Get user credentials — CLI args or interactive prompt
|
||||||
|
4. Authenticate with platform — POST /api/v1/auth/sign-in → JWT
|
||||||
|
5. Select a drone — auto-select if only one, otherwise interactive list
|
||||||
|
6. Connect Socket.IO — JWT auth, websocket transport
|
||||||
|
7. Start scheduler — empty initially
|
||||||
|
8. Fetch projects — GET /api/v1/projects
|
||||||
|
9. Schedule enabled tasks — create CronJob for each task's crontab
|
||||||
|
10. Start heartbeat — 19s interval, prevents drone timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Stop all CronJobs — SchedulerService.stop()
|
||||||
|
2. Disconnect Socket.IO — reject pending work orders
|
||||||
|
3. Release Redis lock — allow another instance to start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- gadget-code platform running (backend + frontend)
|
||||||
|
- At least one gadget-drone registered and online
|
||||||
|
- Redis running on localhost:6379 (or as configured)
|
||||||
|
- A user account on the platform with projects that have tasks defined
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the monorepo root
|
||||||
|
cd gadget-tasks
|
||||||
|
|
||||||
|
# Development mode (TypeScript, auto-restart)
|
||||||
|
pnpm dev -- --user=admin@example.com --password=secret
|
||||||
|
|
||||||
|
# Or with interactive credential prompt
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Production (build first)
|
||||||
|
pnpm build
|
||||||
|
pnpm start -- --user=admin@example.com --password=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Link globally from the monorepo root
|
||||||
|
pnpm link:global
|
||||||
|
|
||||||
|
# Now available as a system command
|
||||||
|
gadget-tasks --user=admin@example.com --password=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Execution Details
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
|
||||||
|
By default, `concurrency: 1` means tasks execute **sequentially**. If a task fires while the drone is busy with another task, the new task waits for the drone to become available. This is the correct model — drones process one work order at a time.
|
||||||
|
|
||||||
|
If you increase `concurrency`, you need multiple drones available. Each task requires a drone lock, and a drone can only be locked to one session at a time.
|
||||||
|
|
||||||
|
### Work Order Tracking
|
||||||
|
|
||||||
|
When `submitPrompt` is called, the callback provides a `turnId`. gadget-tasks stores a Promise resolver in a `pendingWorkOrders` map keyed by `turnId`. When the server emits `workOrderComplete(turnId, success, message)`, the corresponding Promise is resolved, unblocking the task execution.
|
||||||
|
|
||||||
|
### Session Heartbeat
|
||||||
|
|
||||||
|
gadget-tasks sends a `sessionHeartbeat` every 19 seconds (same interval as the browser IDE). This prevents the drone's 120-second heartbeat timeout from firing while a task is active.
|
||||||
|
|
||||||
|
### Error Recovery
|
||||||
|
|
||||||
|
If gadget-tasks crashes while a task is being processed:
|
||||||
|
- The drone continues processing the work order independently
|
||||||
|
- The session and ChatTurn records are preserved in the database
|
||||||
|
- On restart, gadget-tasks creates **new** sessions for tasks that fire
|
||||||
|
- Old sessions are visible in the browser IDE via their ChatTurn records
|
||||||
|
|
||||||
|
No special crash recovery is needed in gadget-tasks (unlike gadget-drone, which has crash recovery for incomplete work orders).
|
||||||
|
|
||||||
|
## What gadget-tasks Does NOT Do
|
||||||
|
|
||||||
|
| ❌ Does NOT | ✅ Instead |
|
||||||
|
|------------|----------|
|
||||||
|
| Connect to MongoDB | Uses REST API to read/write data |
|
||||||
|
| Define Mongoose models | Uses `@gadget/api` TypeScript interfaces |
|
||||||
|
| Call AI APIs | Submits prompts through the platform → drone |
|
||||||
|
| Build system prompts | gadget-code builds prompts from canonical templates |
|
||||||
|
| Execute tools | The drone executes tools via `@gadget/ai-toolbox` |
|
||||||
|
| Manage workspaces | The drone manages workspace directories |
|
||||||
|
| Store credentials in config | Provides them at startup via CLI or prompt |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [README](../README.md) — Project overview and quick start
|
||||||
|
- [Agent Toolbox](./agent-toolbox.md) — `@gadget/ai-toolbox` tool implementations (used by drones, not gadget-tasks)
|
||||||
|
- [Socket Protocol](./socket-protocol.md) — Socket.IO protocol between IDE, platform, and drone
|
||||||
|
- [Drone Documentation](../gadget-drone/docs/gadget-drone.md) — How drones execute work orders
|
||||||
@ -13,7 +13,7 @@ Gadget Code is a self-hosted, open source, Enterprise-tier Agentic Integrated De
|
|||||||
|
|
||||||
A variety of people will use Gadget Code in a variety of ways to work primarily on software projects, but also other tasks and goals.
|
A variety of people will use Gadget Code in a variety of ways to work primarily on software projects, but also other tasks and goals.
|
||||||
|
|
||||||
- **Project managers** will have the kind of insight that's simply impoisslbe to have in other systems that can't have a conversation about what's going on inside of them.
|
- **Project managers** will have the kind of insight that's simply impossble to have in other systems that can't have a conversation about what's going on inside of them.
|
||||||
- **Developers** will edit code by hand, or by describing intent to their hand-tuned agents. Plural.
|
- **Developers** will edit code by hand, or by describing intent to their hand-tuned agents. Plural.
|
||||||
- **Quality Assurance (QA)** professionals receive a dedicated MODE for developing and executuing tests, documentting bugs, or (sometimes) using Gadget to simply fix them and move on.
|
- **Quality Assurance (QA)** professionals receive a dedicated MODE for developing and executuing tests, documentting bugs, or (sometimes) using Gadget to simply fix them and move on.
|
||||||
- **Operations (NOC, DevOps, etc.)** also receives a dedicated mode for orchestrating and automating your hosting infrastructure, securing it, hardening it, monitoring it, and scaling it.
|
- **Operations (NOC, DevOps, etc.)** also receives a dedicated mode for orchestrating and automating your hosting infrastructure, securing it, hardening it, monitoring it, and scaling it.
|
||||||
|
|||||||
@ -222,6 +222,25 @@ export interface AuthResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectSkill {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
modes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTask {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
mode: string;
|
||||||
|
crontab: string;
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastRun?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
_id: string;
|
_id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -230,6 +249,10 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills: ProjectSkill[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
@ -246,6 +269,8 @@ export const projectApi = {
|
|||||||
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
||||||
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
||||||
api.post<Project>("/api/v1/projects", data),
|
api.post<Project>("/api/v1/projects", data),
|
||||||
|
pull: (gitUrl: string) =>
|
||||||
|
api.post<Project>("/api/v1/projects/pull", { gitUrl }),
|
||||||
update: (
|
update: (
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
@ -253,6 +278,10 @@ export const projectApi = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
description: string;
|
||||||
|
system: string;
|
||||||
|
skills: ProjectSkill[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
}>,
|
}>,
|
||||||
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import slug from "slug";
|
import slug from "slug";
|
||||||
import type { User, Project } from "../lib/api";
|
import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
projectApi,
|
projectApi,
|
||||||
droneApi,
|
droneApi,
|
||||||
@ -116,23 +116,197 @@ function NewProjectForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PullProjectForm({
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: (project: Project) => void;
|
||||||
|
}) {
|
||||||
|
const [gitUrl, setGitUrl] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!gitUrl.trim()) {
|
||||||
|
setError("Git Repository URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await projectApi.pull(gitUrl.trim());
|
||||||
|
onSuccess(project);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to pull project");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Pull New Project</h2>
|
||||||
|
<p className="text-sm text-text-muted mb-6">
|
||||||
|
Import an existing project from a git repository. The project name and
|
||||||
|
slug will be derived automatically from the repository URL.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
|
Git Repository URL *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gitUrl}
|
||||||
|
onChange={(e) => setGitUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
HTTPS or SSH format (e.g., git@github.com:user/repo.git)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Pulling..." : "Pull Project"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface EditProjectFormProps {
|
interface EditProjectFormProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
providers: AiProvider[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODE_OPTIONS = [
|
||||||
|
{ value: "plan", label: "Plan", color: "bg-blue-900/50 text-blue-300" },
|
||||||
|
{ value: "build", label: "Build", color: "bg-green-900/50 text-green-300" },
|
||||||
|
{ value: "test", label: "Test", color: "bg-yellow-900/50 text-yellow-300" },
|
||||||
|
{ value: "ship", label: "Ship", color: "bg-purple-900/50 text-purple-300" },
|
||||||
|
{ value: "dev", label: "Dev", color: "bg-red-900/50 text-red-300" },
|
||||||
|
];
|
||||||
|
|
||||||
function EditProjectForm({
|
function EditProjectForm({
|
||||||
project,
|
project,
|
||||||
|
providers,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: EditProjectFormProps) {
|
}: EditProjectFormProps) {
|
||||||
const [name, setName] = useState(project.name);
|
const [name, setName] = useState(project.name);
|
||||||
const [slugValue, setSlugValue] = useState(project.slug);
|
const [slugValue, setSlugValue] = useState(project.slug);
|
||||||
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
|
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
|
||||||
|
const [description, setDescription] = useState(project.description || "");
|
||||||
|
const [system, setSystem] = useState(project.system || "");
|
||||||
|
const [skills, setSkills] = useState<ProjectSkill[]>(project.skills || []);
|
||||||
|
const [tasks, setTasks] = useState<ProjectTask[]>(project.tasks || []);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Track which skills/tasks are expanded
|
||||||
|
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleSkillExpanded = (id: string) => {
|
||||||
|
setExpandedSkills((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTaskExpanded = (id: string) => {
|
||||||
|
setExpandedTasks((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSkill = () => {
|
||||||
|
const newSkill: ProjectSkill = {
|
||||||
|
_id: crypto.randomUUID(),
|
||||||
|
name: "",
|
||||||
|
content: "",
|
||||||
|
modes: [],
|
||||||
|
};
|
||||||
|
setSkills([...skills, newSkill]);
|
||||||
|
setExpandedSkills((prev) => new Set(prev).add(newSkill._id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSkill = (id: string) => {
|
||||||
|
setSkills(skills.filter((s) => s._id !== id));
|
||||||
|
setExpandedSkills((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSkill = (id: string, updates: Partial<ProjectSkill>) => {
|
||||||
|
setSkills(skills.map((s) => (s._id === id ? { ...s, ...updates } : s)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSkillMode = (id: string, mode: string) => {
|
||||||
|
const skill = skills.find((s) => s._id === id);
|
||||||
|
if (!skill) return;
|
||||||
|
const modes = skill.modes.includes(mode)
|
||||||
|
? skill.modes.filter((m) => m !== mode)
|
||||||
|
: [...skill.modes, mode];
|
||||||
|
updateSkill(id, { modes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTask = () => {
|
||||||
|
const newTask: ProjectTask = {
|
||||||
|
_id: crypto.randomUUID(),
|
||||||
|
name: "",
|
||||||
|
provider: providers[0]?._id || "",
|
||||||
|
selectedModel: "",
|
||||||
|
mode: "build",
|
||||||
|
crontab: "0 0 * * * *",
|
||||||
|
content: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
setExpandedTasks((prev) => new Set(prev).add(newTask._id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTask = (id: string) => {
|
||||||
|
setTasks(tasks.filter((t) => t._id !== id));
|
||||||
|
setExpandedTasks((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTask = (id: string, updates: Partial<ProjectTask>) => {
|
||||||
|
setTasks(tasks.map((t) => (t._id === id ? { ...t, ...updates } : t)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name || !slugValue) {
|
if (!name || !slugValue) {
|
||||||
@ -148,6 +322,10 @@ function EditProjectForm({
|
|||||||
name,
|
name,
|
||||||
slug: slug(slugValue),
|
slug: slug(slugValue),
|
||||||
gitUrl: gitUrl || undefined,
|
gitUrl: gitUrl || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
system: system || undefined,
|
||||||
|
skills: skills.filter((s) => s.name.trim()), // only send skills with names
|
||||||
|
tasks: tasks.filter((t) => t.name.trim()), // only send tasks with names
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -157,10 +335,14 @@ function EditProjectForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
|
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Basic Fields */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-text-secondary mb-1">
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
Project Name *
|
Project Name *
|
||||||
@ -169,12 +351,9 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
className={inputClass}
|
||||||
placeholder="My Project"
|
placeholder="My Project"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
|
||||||
Used for display purposes only
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-text-secondary mb-1">
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
@ -184,7 +363,7 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={slugValue}
|
value={slugValue}
|
||||||
onChange={(e) => setSlugValue(e.target.value)}
|
onChange={(e) => setSlugValue(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
className={inputClass}
|
||||||
placeholder="my-project"
|
placeholder="my-project"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
@ -200,10 +379,431 @@ function EditProjectForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={gitUrl}
|
value={gitUrl}
|
||||||
onChange={(e) => setGitUrl(e.target.value)}
|
onChange={(e) => setGitUrl(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
className={inputClass}
|
||||||
placeholder="https://github.com/user/repo.git"
|
placeholder="https://github.com/user/repo.git"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className={`${inputClass} resize-y`}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Brief description of the project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Prompt */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-secondary mb-1">
|
||||||
|
System Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={system}
|
||||||
|
onChange={(e) => setSystem(e.target.value)}
|
||||||
|
className={`${inputClass} resize-y font-mono text-sm`}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Project-specific instructions appended to the agent's system prompt..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
Appended to the agent's system prompt when working on this project.
|
||||||
|
Use this to provide project-specific instructions, such as how to
|
||||||
|
manage API servers as subprocesses during development.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Editor */}
|
||||||
|
<div className="border border-border-default rounded">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
|
||||||
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
Skills ({skills.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSkill}
|
||||||
|
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Skill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-text-muted">
|
||||||
|
No skills defined. Skills provide project-specific knowledge that
|
||||||
|
agents can discover using the <code className="text-text-secondary">list_skills()</code> and{" "}
|
||||||
|
<code className="text-text-secondary">read_skill()</code> tools.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{skills.map((skill) => {
|
||||||
|
const isExpanded = expandedSkills.has(skill._id);
|
||||||
|
return (
|
||||||
|
<div key={skill._id}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
|
||||||
|
onClick={() => toggleSkillExpanded(skill._id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-text-muted text-xs">
|
||||||
|
{isExpanded ? "▼" : "▶"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text-primary truncate">
|
||||||
|
{skill.name || "Unnamed Skill"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{skill.modes.map((mode) => {
|
||||||
|
const opt = MODE_OPTIONS.find(
|
||||||
|
(m) => m.value === mode,
|
||||||
|
);
|
||||||
|
return opt ? (
|
||||||
|
<span
|
||||||
|
key={mode}
|
||||||
|
className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeSkill(skill._id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-text-muted hover:text-red-400 transition-colors"
|
||||||
|
title="Remove skill"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3 pt-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={skill.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSkill(skill._id, {
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Skill name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={skill.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSkill(skill._id, {
|
||||||
|
content: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`${inputClass} resize-y font-mono text-sm`}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Skill instructions and knowledge..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
What the agent reads via the{" "}
|
||||||
|
<code>read_skill()</code> tool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Available Modes
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MODE_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={skill.modes.includes(opt.value)}
|
||||||
|
onChange={() =>
|
||||||
|
toggleSkillMode(skill._id, opt.value)
|
||||||
|
}
|
||||||
|
className="rounded border-border-default bg-bg-tertiary"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 text-xs rounded ${opt.color}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
Which modes can see this skill via{" "}
|
||||||
|
<code>list_skills()</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks Editor */}
|
||||||
|
<div className="border border-border-default rounded">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-border-subtle">
|
||||||
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||||
|
Tasks ({tasks.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTask}
|
||||||
|
className="px-3 py-1 text-xs border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-text-muted">
|
||||||
|
No tasks defined. Tasks are scheduled agent runs with a crontab
|
||||||
|
schedule.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const isExpanded = expandedTasks.has(task._id);
|
||||||
|
const taskProvider = providers.find(
|
||||||
|
(p) => p._id === task.provider,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={task._id}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-bg-tertiary/50 transition-colors"
|
||||||
|
onClick={() => toggleTaskExpanded(task._id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-text-muted text-xs">
|
||||||
|
{isExpanded ? "▼" : "▶"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text-primary truncate">
|
||||||
|
{task.name || "Unnamed Task"}
|
||||||
|
</span>
|
||||||
|
{task.enabled ? (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.crontab && (
|
||||||
|
<span className="font-mono text-[10px] text-text-muted">
|
||||||
|
{task.crontab}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTask(task._id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-text-muted hover:text-red-400 transition-colors"
|
||||||
|
title="Remove task"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3 pt-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={task.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Task name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Provider
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={task.provider}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTask(task._id, {
|
||||||
|
provider: e.target.value,
|
||||||
|
selectedModel: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Select provider</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p._id} value={p._id}>
|
||||||
|
{p.name} ({p.apiType})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={task.selectedModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
selectedModel: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={!taskProvider}
|
||||||
|
>
|
||||||
|
<option value="">Select model</option>
|
||||||
|
{taskProvider?.models
|
||||||
|
?.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
)
|
||||||
|
.map((model) => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.name}
|
||||||
|
{model.parameterLabel
|
||||||
|
? ` (${model.parameterLabel})`
|
||||||
|
: ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={task.mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
mode: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="plan">Plan</option>
|
||||||
|
<option value="build">Build</option>
|
||||||
|
<option value="test">Test</option>
|
||||||
|
<option value="ship">Ship</option>
|
||||||
|
<option value="dev">Dev</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Schedule
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={task.crontab}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
crontab: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`${inputClass} font-mono text-sm`}
|
||||||
|
placeholder="0 0 * * * *"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
6-field cron: sec min hour day month weekday
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-text-muted mb-1">
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={task.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
content: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`${inputClass} resize-y font-mono text-sm`}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Task prompt for the agent..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={task.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateTask(task._id, {
|
||||||
|
enabled: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded border-border-default bg-bg-tertiary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@ -228,12 +828,14 @@ function EditProjectForm({
|
|||||||
|
|
||||||
interface ProjectInspectorProps {
|
interface ProjectInspectorProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
providers: AiProvider[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectInspector({
|
function ProjectInspector({
|
||||||
project,
|
project,
|
||||||
|
providers,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: ProjectInspectorProps) {
|
}: ProjectInspectorProps) {
|
||||||
@ -259,6 +861,15 @@ function ProjectInspector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modeBadge = (mode: string) => {
|
||||||
|
const opt = MODE_OPTIONS.find((m) => m.value === mode);
|
||||||
|
return opt ? (
|
||||||
|
<span className={`px-1.5 py-0.5 text-[10px] rounded ${opt.color}`}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
@ -266,6 +877,7 @@ function ProjectInspector({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<EditProjectForm
|
<EditProjectForm
|
||||||
project={project}
|
project={project}
|
||||||
|
providers={providers}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@ -274,7 +886,7 @@ function ProjectInspector({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
<div className="text-sm text-text-muted mb-1">Name</div>
|
<div className="text-sm text-text-muted mb-1">Name</div>
|
||||||
@ -290,6 +902,16 @@ function ProjectInspector({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
|
<div className="text-sm text-text-muted mb-1">Description</div>
|
||||||
|
<div className="text-text-primary text-sm">
|
||||||
|
{project.description || (
|
||||||
|
<span className="text-text-muted">Not configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
<div className="text-sm text-text-muted mb-1">Git URL</div>
|
<div className="text-sm text-text-muted mb-1">Git URL</div>
|
||||||
<div className="text-text-primary font-mono text-sm break-all">
|
<div className="text-text-primary font-mono text-sm break-all">
|
||||||
@ -299,6 +921,75 @@ function ProjectInspector({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* System Prompt */}
|
||||||
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
|
<div className="text-sm text-text-muted mb-1">System Prompt</div>
|
||||||
|
<div className="text-text-primary text-sm font-mono">
|
||||||
|
{project.system ? (
|
||||||
|
project.system.length > 200 ? (
|
||||||
|
<span>{project.system.slice(0, 200)}...</span>
|
||||||
|
) : (
|
||||||
|
project.system
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted font-sans">Not configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
|
<div className="text-sm text-text-muted mb-2">Skills ({project.skills?.length || 0})</div>
|
||||||
|
{project.skills && project.skills.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{project.skills.map((skill) => (
|
||||||
|
<div
|
||||||
|
key={skill._id}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-text-primary">{skill.name}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{skill.modes.map((mode) => modeBadge(mode))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted text-sm">No skills defined</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
|
<div className="text-sm text-text-muted mb-2">Tasks ({project.tasks?.length || 0})</div>
|
||||||
|
{project.tasks && project.tasks.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{project.tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task._id}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-text-primary">{task.name}</span>
|
||||||
|
{task.enabled ? (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-green-900/50 text-green-300">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-900/50 text-gray-400">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[10px] text-text-muted">
|
||||||
|
{task.crontab}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted text-sm">No tasks defined</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||||
<div className="text-sm text-text-muted mb-1">Status</div>
|
<div className="text-sm text-text-muted mb-1">Status</div>
|
||||||
@ -818,11 +1509,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewForm, setShowNewForm] = useState(false);
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
const [showPullForm, setShowPullForm] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
loadProviders();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -845,6 +1539,15 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadProviders = async () => {
|
||||||
|
try {
|
||||||
|
const data = await providerApi.getAll();
|
||||||
|
setProviders(data.sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load providers", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectProject = (project: Project) => {
|
const handleSelectProject = (project: Project) => {
|
||||||
navigate(`/projects/${project.slug}`);
|
navigate(`/projects/${project.slug}`);
|
||||||
};
|
};
|
||||||
@ -854,6 +1557,12 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
loadProjects();
|
loadProjects();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProjectPulled = (project: Project) => {
|
||||||
|
setShowPullForm(false);
|
||||||
|
loadProjects();
|
||||||
|
navigate(`/projects/${project.slug}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleProjectDeleted = () => {
|
const handleProjectDeleted = () => {
|
||||||
setSelectedProject(null);
|
setSelectedProject(null);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
@ -908,26 +1617,43 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showForm = showNewForm || showPullForm;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
||||||
{/* Left Sidebar - Project List */}
|
{/* Left Sidebar - Project List */}
|
||||||
<aside className="w-64 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
<aside className="w-64 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
||||||
<div className="p-3 border-b border-border-subtle flex-shrink-0">
|
<div className="p-3 border-b border-border-subtle flex-shrink-0 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewForm(true)}
|
onClick={() => {
|
||||||
|
setShowNewForm(true);
|
||||||
|
setShowPullForm(false);
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
|
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
|
||||||
>
|
>
|
||||||
New Project
|
New Project
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPullForm(true);
|
||||||
|
setShowNewForm(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary hover:text-text-primary transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Pull Project
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{showNewForm ? (
|
{showForm ? (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="text-sm text-text-muted mb-2">
|
<p className="text-sm text-text-muted mb-2">
|
||||||
Creating new project...
|
{showNewForm ? "Creating new project..." : "Pulling project..."}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewForm(false)}
|
onClick={() => {
|
||||||
|
setShowNewForm(false);
|
||||||
|
setShowPullForm(false);
|
||||||
|
}}
|
||||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
← Cancel
|
← Cancel
|
||||||
@ -971,11 +1697,19 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
onSuccess={handleProjectCreated}
|
onSuccess={handleProjectCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : showPullForm ? (
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<PullProjectForm
|
||||||
|
onCancel={() => setShowPullForm(false)}
|
||||||
|
onSuccess={handleProjectPulled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : selectedProject ? (
|
) : selectedProject ? (
|
||||||
<>
|
<>
|
||||||
{/* Center - Project Inspector */}
|
{/* Center - Project Inspector */}
|
||||||
<ProjectInspector
|
<ProjectInspector
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
|
providers={providers}
|
||||||
onDelete={handleProjectDeleted}
|
onDelete={handleProjectDeleted}
|
||||||
onUpdate={handleProjectUpdated}
|
onUpdate={handleProjectUpdated}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,10 +3,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Gadget Code - A self-hosted Agentic Engineering Platform (AEP).",
|
"description": "Gadget Code - A self-hosted Agentic Engineering Platform (AEP).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
|
||||||
"gadget-code": "./dist/web-cli.js",
|
|
||||||
"gadget-code-web": "./dist/web-app.js"
|
|
||||||
},
|
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm build:backend",
|
"build": "pnpm build:backend",
|
||||||
|
|||||||
@ -28,9 +28,11 @@ export class ProjectApiControllerV1 extends DtpController {
|
|||||||
|
|
||||||
this.router.get("/", this.getProjects.bind(this));
|
this.router.get("/", this.getProjects.bind(this));
|
||||||
this.router.post("/", this.createProject.bind(this));
|
this.router.post("/", this.createProject.bind(this));
|
||||||
|
this.router.post("/pull", this.pullProject.bind(this));
|
||||||
this.router.get("/:projectId", this.getProject.bind(this));
|
this.router.get("/:projectId", this.getProject.bind(this));
|
||||||
this.router.put("/:projectId", this.updateProject.bind(this));
|
this.router.put("/:projectId", this.updateProject.bind(this));
|
||||||
this.router.delete("/:projectId", this.deleteProject.bind(this));
|
this.router.delete("/:projectId", this.deleteProject.bind(this));
|
||||||
|
this.router.patch("/:projectId/tasks/:taskId/lastRun", this.updateTaskLastRun.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(req: Request, res: Response): Promise<void> {
|
async getProjects(req: Request, res: Response): Promise<void> {
|
||||||
@ -81,6 +83,65 @@ export class ProjectApiControllerV1 extends DtpController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async pullProject(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { gitUrl } = req.body;
|
||||||
|
|
||||||
|
if (!gitUrl || typeof gitUrl !== "string") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "gitUrl is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the URL is parseable
|
||||||
|
const trimmedUrl = gitUrl.trim();
|
||||||
|
const isSshFormat = trimmedUrl.includes(":") && !trimmedUrl.startsWith("http") && !trimmedUrl.startsWith("ssh://");
|
||||||
|
if (!isSshFormat) {
|
||||||
|
try {
|
||||||
|
new URL(trimmedUrl);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "invalid git URL format",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await projectService.createFromGitUrl(req.user, trimmedUrl);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 400) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate gitUrl (unique partial index violation)
|
||||||
|
if (error.code === 11000) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "a project with this git URL already exists",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.error("failed to pull project", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to pull project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getProject(req: Request, res: Response): Promise<void> {
|
async getProject(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = req.params.projectId as string;
|
const id = req.params.projectId as string;
|
||||||
@ -136,12 +197,16 @@ export class ProjectApiControllerV1 extends DtpController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, slug, gitUrl, status } = req.body;
|
const { name, slug, gitUrl, status, description, system, skills, tasks } = req.body;
|
||||||
const updated = await projectService.update(project, {
|
const updated = await projectService.update(project, {
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
gitUrl,
|
gitUrl,
|
||||||
status: status as ProjectStatus,
|
status: status as ProjectStatus,
|
||||||
|
description,
|
||||||
|
system,
|
||||||
|
skills,
|
||||||
|
tasks,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
@ -192,6 +257,49 @@ export class ProjectApiControllerV1 extends DtpController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTaskLastRun(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const projectId = req.params.projectId as string;
|
||||||
|
const taskId = req.params.taskId as string;
|
||||||
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "sessionId is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await projectService.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "project not found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = project.user as IUser;
|
||||||
|
if (user._id !== req.user._id) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectService.updateTaskLastRun(projectId, taskId, sessionId);
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to update task lastRun", { error });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "failed to update task lastRun",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProjectApiControllerV1;
|
export default ProjectApiControllerV1;
|
||||||
|
|||||||
@ -4,9 +4,34 @@
|
|||||||
|
|
||||||
import { Schema, model } from "mongoose";
|
import { Schema, model } from "mongoose";
|
||||||
|
|
||||||
import { ProjectStatus, IProject } from "@gadget/api";
|
import {
|
||||||
|
ProjectStatus,
|
||||||
|
IProject,
|
||||||
|
IProjectSkill,
|
||||||
|
ChatSessionMode,
|
||||||
|
IProjectTask,
|
||||||
|
} from "@gadget/api";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export const ProjectSkillSchema = new Schema<IProjectSkill>({
|
||||||
|
_id: { type: String, default: () => nanoid() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
modes: { type: [String], enum: ChatSessionMode, required: true },
|
||||||
|
content: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectTaskSchema = new Schema<IProjectTask>({
|
||||||
|
_id: { type: String, default: () => nanoid() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
provider: { type: String, required: true, ref: "AiProvider" },
|
||||||
|
selectedModel: { type: String, required: true },
|
||||||
|
mode: { type: String, enum: ChatSessionMode, required: true },
|
||||||
|
crontab: { type: String, required: true },
|
||||||
|
content: { type: String, required: true },
|
||||||
|
enabled: { type: Boolean, default: true, required: true },
|
||||||
|
lastRun: { type: String, ref: "ChatSession" },
|
||||||
|
});
|
||||||
|
|
||||||
export const ProjectSchema = new Schema<IProject>({
|
export const ProjectSchema = new Schema<IProject>({
|
||||||
_id: { type: String, default: () => nanoid() },
|
_id: { type: String, default: () => nanoid() },
|
||||||
createdAt: { type: Date, default: Date.now, required: true },
|
createdAt: { type: Date, default: Date.now, required: true },
|
||||||
@ -20,6 +45,10 @@ export const ProjectSchema = new Schema<IProject>({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
gitUrl: { type: String },
|
gitUrl: { type: String },
|
||||||
|
description: { type: String },
|
||||||
|
system: { type: String },
|
||||||
|
skills: { type: [ProjectSkillSchema], default: [], required: true },
|
||||||
|
tasks: { type: [ProjectTaskSchema], default: [], required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
ProjectSchema.index(
|
ProjectSchema.index(
|
||||||
|
|||||||
@ -381,6 +381,15 @@ class ChatSessionService extends DtpService {
|
|||||||
.replace("{{session_block}}", sessionBlock)
|
.replace("{{session_block}}", sessionBlock)
|
||||||
.replace("{{persona_block}}", personaBlock);
|
.replace("{{persona_block}}", personaBlock);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Project System Prompt — appended as an appendix when the project
|
||||||
|
* has a `system` field configured. This allows project-specific
|
||||||
|
* instructions to be injected without modifying mode templates.
|
||||||
|
*/
|
||||||
|
if (project.system && project.system.trim().length > 0) {
|
||||||
|
prompt += `\n\n## PROJECT CONFIGURATION\n\n### System Instructions\n${project.system.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import slug from "slug";
|
|||||||
|
|
||||||
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
|
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
|
||||||
|
|
||||||
import { IProject, IUser, ProjectStatus } from "@gadget/api";
|
import { IProject, IProjectSkill, IProjectTask, IUser, ProjectStatus } from "@gadget/api";
|
||||||
import Project from "@/models/project.js";
|
import Project from "@/models/project.js";
|
||||||
|
|
||||||
import { DtpService } from "../lib/service.js";
|
import { DtpService } from "../lib/service.js";
|
||||||
@ -16,6 +16,10 @@ export interface IProjectDefinition {
|
|||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
status?: ProjectStatus;
|
status?: ProjectStatus;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills?: IProjectSkill[];
|
||||||
|
tasks?: IProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProjectService extends DtpService {
|
class ProjectService extends DtpService {
|
||||||
@ -47,6 +51,69 @@ class ProjectService extends DtpService {
|
|||||||
this.log.info("service stopped");
|
this.log.info("service stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a git URL to extract the repository name.
|
||||||
|
* Handles common formats:
|
||||||
|
* https://github.com/user/repo.git
|
||||||
|
* https://github.com/user/repo
|
||||||
|
* git@github.com:user/repo.git
|
||||||
|
* ssh://git@github.com/user/repo.git
|
||||||
|
*/
|
||||||
|
parseGitUrl(gitUrl: string): { name: string; slug: string } {
|
||||||
|
const url = gitUrl.trim();
|
||||||
|
let repoName: string;
|
||||||
|
|
||||||
|
// SSH format: git@host:owner/repo.git
|
||||||
|
if (url.includes(":") && !url.startsWith("http") && !url.startsWith("ssh://")) {
|
||||||
|
const parts = url.split(":").pop()!;
|
||||||
|
repoName = parts.split("/").pop()!;
|
||||||
|
} else {
|
||||||
|
// HTTP(S) or ssh:// format
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
repoName = segments[segments.length - 1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip .git suffix
|
||||||
|
repoName = repoName.replace(/\.git$/, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: repoName,
|
||||||
|
slug: slug(repoName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a project from a git URL by parsing the URL to derive
|
||||||
|
* the project name and slug automatically. Handles slug collisions
|
||||||
|
* by appending a numeric suffix (e.g., my-repo-2).
|
||||||
|
*/
|
||||||
|
async createFromGitUrl(user: IUser, gitUrl: string): Promise<IProject> {
|
||||||
|
const { name, slug: baseSlug } = this.parseGitUrl(gitUrl);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
const error = new Error("could not derive project name from git URL");
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve slug collisions by appending a numeric suffix
|
||||||
|
let projectSlug = baseSlug;
|
||||||
|
let suffix = 2;
|
||||||
|
while (await Project.findOne({ slug: projectSlug, user: user._id })) {
|
||||||
|
projectSlug = `${baseSlug}-${suffix}`;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info("creating project from git URL", { name, slug: projectSlug, gitUrl });
|
||||||
|
|
||||||
|
return this.create(user, {
|
||||||
|
name,
|
||||||
|
slug: projectSlug,
|
||||||
|
gitUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async create(user: IUser, definition: IProjectDefinition): Promise<IProject> {
|
async create(user: IUser, definition: IProjectDefinition): Promise<IProject> {
|
||||||
const NOW = new Date();
|
const NOW = new Date();
|
||||||
|
|
||||||
@ -95,10 +162,41 @@ class ProjectService extends DtpService {
|
|||||||
if (definition.gitUrl !== project.gitUrl) {
|
if (definition.gitUrl !== project.gitUrl) {
|
||||||
update.$set.gitUrl = definition.gitUrl;
|
update.$set.gitUrl = definition.gitUrl;
|
||||||
}
|
}
|
||||||
|
} else if (definition.gitUrl === undefined && definition.hasOwnProperty('gitUrl')) {
|
||||||
|
// Only unset if explicitly provided as undefined/empty
|
||||||
} else {
|
} else {
|
||||||
update.$unset.gitUrl = 1;
|
update.$unset.gitUrl = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Description — set or unset
|
||||||
|
if (definition.description !== undefined) {
|
||||||
|
if (definition.description !== project.description) {
|
||||||
|
update.$set.description = definition.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System prompt — set or unset
|
||||||
|
if (definition.system !== undefined) {
|
||||||
|
if (definition.system !== project.system) {
|
||||||
|
update.$set.system = definition.system;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills — full array replacement
|
||||||
|
if (definition.skills !== undefined) {
|
||||||
|
update.$set.skills = definition.skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks — full array replacement
|
||||||
|
if (definition.tasks !== undefined) {
|
||||||
|
update.$set.tasks = definition.tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty $unset to avoid MongoDB errors
|
||||||
|
if (Object.keys(update.$unset as Record<string, unknown>).length === 0) {
|
||||||
|
delete (update as any).$unset;
|
||||||
|
}
|
||||||
|
|
||||||
const newProject = await Project.findOneAndUpdate(
|
const newProject = await Project.findOneAndUpdate(
|
||||||
{ _id: project._id },
|
{ _id: project._id },
|
||||||
update,
|
update,
|
||||||
@ -159,6 +257,19 @@ class ProjectService extends DtpService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTaskLastRun(projectId: string, taskId: string, sessionId: string): Promise<void> {
|
||||||
|
const result = await Project.updateOne(
|
||||||
|
{ _id: projectId, "tasks._id": taskId },
|
||||||
|
{ $set: { "tasks.$.lastRun": sessionId } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
throw new Error(`Task ${taskId} not found in project ${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info("task lastRun updated", { projectId, taskId, sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
async delete(project: IProject): Promise<void> {
|
async delete(project: IProject): Promise<void> {
|
||||||
await Project.deleteOne({ _id: project._id });
|
await Project.deleteOne({ _id: project._id });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@ -23,6 +23,23 @@ describe('Project API Endpoints', () => {
|
|||||||
expect(content).toContain('createProject');
|
expect(content).toContain('createProject');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have POST /pull route', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
expect(content).toContain('pullProject');
|
||||||
|
expect(content).toContain('/pull');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register /pull route before /:projectId', () => {
|
||||||
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
|
const pullIndex = content.indexOf('"/pull"');
|
||||||
|
const paramIndex = content.indexOf('"/:projectId"');
|
||||||
|
expect(pullIndex).toBeGreaterThan(0);
|
||||||
|
expect(paramIndex).toBeGreaterThan(0);
|
||||||
|
expect(pullIndex).toBeLessThan(paramIndex);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use requireUser middleware', () => {
|
it('should use requireUser middleware', () => {
|
||||||
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||||
const content = fs.readFileSync(controllerPath, 'utf-8');
|
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||||
@ -55,6 +72,18 @@ describe('Project API Endpoints', () => {
|
|||||||
expect(content).toContain('getForUser');
|
expect(content).toContain('getForUser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have createFromGitUrl method', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('createFromGitUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have parseGitUrl method', () => {
|
||||||
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
|
expect(content).toContain('parseGitUrl');
|
||||||
|
});
|
||||||
|
|
||||||
it('should populate user with password excluded', () => {
|
it('should populate user with password excluded', () => {
|
||||||
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||||
const content = fs.readFileSync(servicePath, 'utf-8');
|
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||||
@ -62,6 +91,60 @@ describe('Project API Endpoints', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Git URL Parsing', () => {
|
||||||
|
// We import the service to test parseGitUrl directly
|
||||||
|
let projectService: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await import(path.join(ROOT_DIR, 'src', 'services', 'project.ts'));
|
||||||
|
projectService = mod.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse HTTPS URL with .git suffix', () => {
|
||||||
|
const result = projectService.parseGitUrl('https://github.com/user/my-repo.git');
|
||||||
|
expect(result.name).toBe('my-repo');
|
||||||
|
expect(result.slug).toBe('my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse HTTPS URL without .git suffix', () => {
|
||||||
|
const result = projectService.parseGitUrl('https://github.com/user/my-repo');
|
||||||
|
expect(result.name).toBe('my-repo');
|
||||||
|
expect(result.slug).toBe('my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse SSH format URL', () => {
|
||||||
|
const result = projectService.parseGitUrl('git@github.com:user/my-repo.git');
|
||||||
|
expect(result.name).toBe('my-repo');
|
||||||
|
expect(result.slug).toBe('my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse SSH URL with ssh:// prefix', () => {
|
||||||
|
const result = projectService.parseGitUrl('ssh://git@github.com/user/my-repo.git');
|
||||||
|
expect(result.name).toBe('my-repo');
|
||||||
|
expect(result.slug).toBe('my-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle repo names with special characters', () => {
|
||||||
|
const result = projectService.parseGitUrl('https://github.com/user/my_cool_repo.git');
|
||||||
|
expect(result.name).toBe('my_cool_repo');
|
||||||
|
// slug() removes underscores entirely
|
||||||
|
expect(result.slug).toBe('mycoolrepo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle repo names with mixed case', () => {
|
||||||
|
const result = projectService.parseGitUrl('https://github.com/user/MyRepo.git');
|
||||||
|
expect(result.name).toBe('MyRepo');
|
||||||
|
// slug() lowercases
|
||||||
|
expect(result.slug).toBe('myrepo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from URL', () => {
|
||||||
|
const result = projectService.parseGitUrl(' https://github.com/user/my-repo.git ');
|
||||||
|
expect(result.name).toBe('my-repo');
|
||||||
|
expect(result.slug).toBe('my-repo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Frontend API Client', () => {
|
describe('Frontend API Client', () => {
|
||||||
it('should add Authorization header with token', () => {
|
it('should add Authorization header with token', () => {
|
||||||
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
@ -81,6 +164,39 @@ describe('Project API Endpoints', () => {
|
|||||||
const content = fs.readFileSync(apiPath, 'utf-8');
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
expect(content).toContain('getAll');
|
expect(content).toContain('getAll');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have pull method for project API', () => {
|
||||||
|
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||||
|
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||||
|
expect(content).toContain('pull:');
|
||||||
|
expect(content).toContain('/projects/pull');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pull Project UI', () => {
|
||||||
|
it('should have PullProjectForm component', () => {
|
||||||
|
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||||
|
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||||
|
expect(content).toContain('PullProjectForm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Pull Project button in sidebar', () => {
|
||||||
|
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||||
|
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||||
|
expect(content).toContain('Pull Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have showPullForm state', () => {
|
||||||
|
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||||
|
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||||
|
expect(content).toContain('showPullForm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have handleProjectPulled handler', () => {
|
||||||
|
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||||
|
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||||
|
expect(content).toContain('handleProjectPulled');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('User Interface', () => {
|
describe('User Interface', () => {
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gadget/ai": "workspace:*",
|
"@gadget/ai": "workspace:*",
|
||||||
|
"@gadget/ai-toolbox": "workspace:*",
|
||||||
"@gadget/api": "workspace:*",
|
"@gadget/api": "workspace:*",
|
||||||
"@gadget/config": "workspace:*",
|
"@gadget/config": "workspace:*",
|
||||||
"@inquirer/prompts": "^8.4.2",
|
"@inquirer/prompts": "^8.4.2",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"openai": "^6.34.0",
|
"openai": "^6.34.0",
|
||||||
"playwright": "1.59.1",
|
"playwright": "1.59.1",
|
||||||
"simple-git": "^3.36.0",
|
"simple-git": "^3.36.0",
|
||||||
|
"simplegit": "^1.0.2",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"turndown": "7.2.2"
|
"turndown": "7.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -52,6 +52,8 @@ import {
|
|||||||
ShellExecTool,
|
ShellExecTool,
|
||||||
SubagentTool,
|
SubagentTool,
|
||||||
SubprocessTool,
|
SubprocessTool,
|
||||||
|
ListSkillsTool,
|
||||||
|
ReadSkillTool,
|
||||||
type DroneToolboxEnvironment,
|
type DroneToolboxEnvironment,
|
||||||
} from "../tools/index.ts";
|
} from "../tools/index.ts";
|
||||||
|
|
||||||
@ -128,9 +130,13 @@ class AgentService extends GadgetService {
|
|||||||
|
|
||||||
// Chat tools — subagent spawning: available in all modes
|
// Chat tools — subagent spawning: available in all modes
|
||||||
const subagentTool = new SubagentTool(this.toolbox);
|
const subagentTool = new SubagentTool(this.toolbox);
|
||||||
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
|
subagentTool.setSpawner((agentType: string, prompt: string) => this.spawnSubagent(agentType, prompt));
|
||||||
this.toolbox.register(subagentTool, readOnlyModes);
|
this.toolbox.register(subagentTool, readOnlyModes);
|
||||||
|
|
||||||
|
// Project tools — skill discovery: available in all modes
|
||||||
|
this.toolbox.register(new ListSkillsTool(this.toolbox), readOnlyModes);
|
||||||
|
this.toolbox.register(new ReadSkillTool(this.toolbox), readOnlyModes);
|
||||||
|
|
||||||
this.log.info("started");
|
this.log.info("started");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,6 +554,8 @@ class AgentService extends GadgetService {
|
|||||||
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
||||||
cacheDir,
|
cacheDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.toolbox.updateProjectContext(project, turn.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async spawnSubagent(
|
async spawnSubagent(
|
||||||
|
|||||||
@ -1,9 +1,27 @@
|
|||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
|
// Re-export from @gadget/ai-toolbox
|
||||||
export { DroneTool } from "./tool.ts";
|
export {
|
||||||
export * from "./chat/index.ts";
|
AiToolbox,
|
||||||
export * from "./system/index.ts";
|
type GadgetToolboxEnvironment as DroneToolboxEnvironment,
|
||||||
export * from "./network/index.ts";
|
FileReadTool,
|
||||||
export * from "./plan/index.ts";
|
FileWriteTool,
|
||||||
|
FileEditTool,
|
||||||
|
ShellExecTool,
|
||||||
|
ListTool,
|
||||||
|
GrepTool,
|
||||||
|
GlobTool,
|
||||||
|
GoogleSearchTool,
|
||||||
|
FetchUrlTool,
|
||||||
|
PlanFileReadTool,
|
||||||
|
PlanFileWriteTool,
|
||||||
|
PlanFileEditTool,
|
||||||
|
PlanListTool,
|
||||||
|
SubagentTool,
|
||||||
|
ListSkillsTool,
|
||||||
|
ReadSkillTool,
|
||||||
|
} from "@gadget/ai-toolbox";
|
||||||
|
|
||||||
|
// Local tools (gadget-drone specific)
|
||||||
|
export { SubprocessTool } from "./system/index.ts";
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
export { FileReadTool } from "./read.ts";
|
|
||||||
export { FileWriteTool } from "./write.ts";
|
|
||||||
export { FileEditTool } from "./edit.ts";
|
|
||||||
export { ShellExecTool } from "./shell.ts";
|
|
||||||
export { ListTool } from "./list.ts";
|
|
||||||
export { GrepTool } from "./grep.ts";
|
|
||||||
export { GlobTool } from "./glob.ts";
|
|
||||||
export { SubprocessTool } from "./subprocess.ts";
|
export { SubprocessTool } from "./subprocess.ts";
|
||||||
|
|||||||
@ -1,261 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { IAiLogger } from "@gadget/ai";
|
|
||||||
import { AiToolbox, type DroneToolboxEnvironment } from "../toolbox.ts";
|
|
||||||
import { SubprocessTool } from "./subprocess.ts";
|
|
||||||
|
|
||||||
vi.mock("../../services/subprocess.ts", () => ({
|
|
||||||
default: {
|
|
||||||
create: vi.fn(),
|
|
||||||
ps: vi.fn(),
|
|
||||||
killPid: vi.fn(),
|
|
||||||
killAll: vi.fn(),
|
|
||||||
haltPid: vi.fn(),
|
|
||||||
getLog: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockLogger: IAiLogger = {
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const env: DroneToolboxEnvironment = {
|
|
||||||
NODE_ENV: "test",
|
|
||||||
workspace: {
|
|
||||||
workspaceDir: "/tmp/workspace",
|
|
||||||
projectDir: "/tmp/workspace/test-project",
|
|
||||||
cacheDir: "/tmp/workspace/.gadget/cache",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SubprocessTool", () => {
|
|
||||||
let toolbox: AiToolbox;
|
|
||||||
let tool: SubprocessTool;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
toolbox = new AiToolbox(env);
|
|
||||||
tool = new SubprocessTool(toolbox);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has the correct name and category", () => {
|
|
||||||
expect(tool.name).toBe("subprocess");
|
|
||||||
expect(tool.category).toBe("system");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defines the subprocess tool definition with all commands", () => {
|
|
||||||
const params = tool.definition.function.parameters as Record<string, any>;
|
|
||||||
const cmd = params.properties?.cmd as Record<string, any> | undefined;
|
|
||||||
const cmds: string[] = cmd?.enum ?? [];
|
|
||||||
|
|
||||||
expect(tool.definition.type).toBe("function");
|
|
||||||
expect(tool.definition.function.name).toBe("subprocess");
|
|
||||||
expect(cmds).toContain("create");
|
|
||||||
expect(cmds).toContain("list");
|
|
||||||
expect(cmds).toContain("kill");
|
|
||||||
expect(cmds).toContain("killAll");
|
|
||||||
expect(cmds).toContain("halt");
|
|
||||||
expect(cmds).toContain("log");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns error when cmd is missing", async () => {
|
|
||||||
const result = await tool.execute({}, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("cmd");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns error when cmd is invalid", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "invalid" }, mockLogger);
|
|
||||||
expect(result).toContain("INVALID_PARAMETER");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=create", () => {
|
|
||||||
it("returns error when command is missing", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "create" }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("command");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls SubProcessService.create with args", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
pid: 42,
|
|
||||||
stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stdout.log",
|
|
||||||
stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stderr.log",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await tool.execute(
|
|
||||||
{ cmd: "create", command: "node", args: ["server.js"] },
|
|
||||||
mockLogger,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(SubProcessService.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ slug: "test-project" }),
|
|
||||||
"node",
|
|
||||||
["server.js"],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toContain("SUBPROCESS CREATED");
|
|
||||||
expect(result).toContain("pid: 42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles args as non-array gracefully", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
pid: 7,
|
|
||||||
stdoutFileName: "/tmp/subproc-7.stdout.log",
|
|
||||||
stderrFileName: "/tmp/subproc-7.stderr.log",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await tool.execute(
|
|
||||||
{ cmd: "create", command: "echo", args: "hello" },
|
|
||||||
mockLogger,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toContain("pid: 7");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=list", () => {
|
|
||||||
it("returns empty list message when no processes", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.ps as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "list" }, mockLogger);
|
|
||||||
expect(result).toContain("(no running subprocesses)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists running processes", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.ps as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
||||||
{
|
|
||||||
pid: 1,
|
|
||||||
project: { slug: "project-a" },
|
|
||||||
createdAt: new Date("2026-01-01"),
|
|
||||||
updatedAt: new Date("2026-01-02"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pid: 2,
|
|
||||||
project: { slug: "project-b" },
|
|
||||||
createdAt: new Date("2026-01-03"),
|
|
||||||
updatedAt: new Date("2026-01-04"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "list" }, mockLogger);
|
|
||||||
expect(result).toContain("SUBPROCESS LIST");
|
|
||||||
expect(result).toContain("pid: 1");
|
|
||||||
expect(result).toContain("pid: 2");
|
|
||||||
expect(result).toContain("project-a");
|
|
||||||
expect(result).toContain("project-b");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=kill", () => {
|
|
||||||
it("returns error when pid is missing", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "kill" }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("pid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls killPid and returns success", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.killPid as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "kill", pid: 42 }, mockLogger);
|
|
||||||
expect(SubProcessService.killPid).toHaveBeenCalledWith(42);
|
|
||||||
expect(result).toContain("SUBPROCESS KILLED");
|
|
||||||
expect(result).toContain("pid: 42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles service errors", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.killPid as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "kill", pid: 99 }, mockLogger);
|
|
||||||
expect(result).toContain("OPERATION_FAILED");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=killAll", () => {
|
|
||||||
it("calls killAll and returns success", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.killAll as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "killAll" }, mockLogger);
|
|
||||||
expect(SubProcessService.killAll).toHaveBeenCalledOnce();
|
|
||||||
expect(result).toContain("SUBPROCESS KILLALL");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=halt", () => {
|
|
||||||
it("returns error when pid is missing", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "halt" }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("pid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls haltPid and returns success", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.haltPid as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await tool.execute({ cmd: "halt", pid: 42 }, mockLogger);
|
|
||||||
expect(SubProcessService.haltPid).toHaveBeenCalledWith(42);
|
|
||||||
expect(result).toContain("SUBPROCESS HALTED");
|
|
||||||
expect(result).toContain("pid: 42");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmd=log", () => {
|
|
||||||
it("returns error when pid is missing", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "log", which: "stdout" }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("pid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns error when which is missing", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "log", pid: 42 }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("which");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns error when which is invalid", async () => {
|
|
||||||
const result = await tool.execute({ cmd: "log", pid: 42, which: "invalid" }, mockLogger);
|
|
||||||
expect(result).toContain("MISSING_PARAMETER");
|
|
||||||
expect(result).toContain("which");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads stdout log content", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.getLog as ReturnType<typeof vi.fn>).mockResolvedValue("line1\nline2\nline3");
|
|
||||||
|
|
||||||
const result = await tool.execute(
|
|
||||||
{ cmd: "log", pid: 42, which: "stdout" },
|
|
||||||
mockLogger,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stdout");
|
|
||||||
expect(result).toContain("SUBPROCESS LOG (stdout)");
|
|
||||||
expect(result).toContain("pid: 42");
|
|
||||||
expect(result).toContain("line1");
|
|
||||||
expect(result).toContain("line2");
|
|
||||||
expect(result).toContain("line3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads stderr log content", async () => {
|
|
||||||
const SubProcessService = (await import("../../services/subprocess.ts")).default;
|
|
||||||
(SubProcessService.getLog as ReturnType<typeof vi.fn>).mockResolvedValue("error: something broke");
|
|
||||||
|
|
||||||
const result = await tool.execute(
|
|
||||||
{ cmd: "log", pid: 42, which: "stderr" },
|
|
||||||
mockLogger,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stderr");
|
|
||||||
expect(result).toContain("SUBPROCESS LOG (stderr)");
|
|
||||||
expect(result).toContain("error: something broke");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { formatError } from "@gadget/ai";
|
import { formatError } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "@gadget/ai-toolbox";
|
||||||
import SubProcessService from "../../services/subprocess.ts";
|
import SubProcessService from "../../services/subprocess.ts";
|
||||||
|
|
||||||
const VALID_CMDS = [
|
const VALID_CMDS = [
|
||||||
@ -17,7 +17,7 @@ const VALID_CMDS = [
|
|||||||
|
|
||||||
type SubprocessCmd = (typeof VALID_CMDS)[number];
|
type SubprocessCmd = (typeof VALID_CMDS)[number];
|
||||||
|
|
||||||
export class SubprocessTool extends DroneTool {
|
export class SubprocessTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "subprocess";
|
return "subprocess";
|
||||||
}
|
}
|
||||||
|
|||||||
1
gadget-tasks/.gitignore
vendored
Normal file
1
gadget-tasks/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
13
gadget-tasks/gadget-tasks.yaml
Normal file
13
gadget-tasks/gadget-tasks.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
timezone: America/New_York
|
||||||
|
platform:
|
||||||
|
baseUrl: https://code-dev.g4dge7.com:5174
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
concurrency: 1
|
||||||
|
logging:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
|
file:
|
||||||
|
enabled: true
|
||||||
|
path: ~/logs/gadget-tasks
|
||||||
39
gadget-tasks/package.json
Normal file
39
gadget-tasks/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "gadget-tasks",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gadget Code scheduled task worker process",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"gadget-tasks": "./dist/gadget-tasks.js"
|
||||||
|
},
|
||||||
|
"main": "./dist/gadget-tasks.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/gadget-tasks.ts",
|
||||||
|
"dev:watch": "tsx watch src/gadget-tasks.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/gadget-tasks.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"gadget",
|
||||||
|
"tasks",
|
||||||
|
"cron",
|
||||||
|
"scheduler",
|
||||||
|
"worker"
|
||||||
|
],
|
||||||
|
"author": "Rob Colbert",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||||
|
"dependencies": {
|
||||||
|
"@gadget/api": "workspace:*",
|
||||||
|
"@gadget/config": "workspace:*",
|
||||||
|
"@inquirer/prompts": "^8.4.2",
|
||||||
|
"cron": "^4.3.1",
|
||||||
|
"ioredis": "^5.6.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
gadget-tasks/src/config/env.ts
Normal file
88
gadget-tasks/src/config/env.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// config/env.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import path, { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { loadGadgetTasksConfig, resolvePath } from "@gadget/config";
|
||||||
|
import type PackageJson from "../../package.json";
|
||||||
|
import { GadgetLog, GadgetLogFile } from "@gadget/api";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// INSTALL_DIR: where the package is installed (for loading assets, etc.)
|
||||||
|
export const INSTALL_DIR = path.resolve(__dirname, "..", "..");
|
||||||
|
|
||||||
|
// Load YAML configuration
|
||||||
|
const yamlConfig = loadGadgetTasksConfig();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!yamlConfig.platform?.baseUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"Configuration error: platform.baseUrl is required in gadget-tasks.yaml\n" +
|
||||||
|
"See documentation: ./docs/configuration.md",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||||
|
const fs = await import("node:fs");
|
||||||
|
const file = await fs.promises.readFile(filePath);
|
||||||
|
return JSON.parse(file.toString("utf-8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable no-process-env */
|
||||||
|
export default {
|
||||||
|
NODE_ENV: process.env.NODE_ENV || "develop",
|
||||||
|
timezone: yamlConfig.timezone || "America/New_York",
|
||||||
|
installDir: INSTALL_DIR,
|
||||||
|
pkg: await readJsonFile<typeof PackageJson>(
|
||||||
|
path.join(INSTALL_DIR, "package.json"),
|
||||||
|
),
|
||||||
|
platform: {
|
||||||
|
baseUrl: yamlConfig.platform.baseUrl,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: yamlConfig.redis?.host || "localhost",
|
||||||
|
port: yamlConfig.redis?.port || 6379,
|
||||||
|
password: yamlConfig.redis?.password,
|
||||||
|
keyPrefix: yamlConfig.redis?.keyPrefix || "gadget:",
|
||||||
|
},
|
||||||
|
concurrency: yamlConfig.concurrency || 1,
|
||||||
|
log: {
|
||||||
|
console: {
|
||||||
|
enabled: yamlConfig.logging?.console?.enabled === true,
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
enabled: yamlConfig.logging?.file?.enabled === true,
|
||||||
|
path: yamlConfig.logging?.file?.path
|
||||||
|
? resolvePath(yamlConfig.logging.file.path)
|
||||||
|
: path.join(INSTALL_DIR, "logs"),
|
||||||
|
name: yamlConfig.logging?.file?.name || "gadget-tasks",
|
||||||
|
maxWritesPerFile: yamlConfig.logging?.file?.maxWritesPerFile || 10000,
|
||||||
|
maxFiles: yamlConfig.logging?.file?.maxFiles || 10,
|
||||||
|
},
|
||||||
|
levels: {
|
||||||
|
debug: yamlConfig.logging?.levels?.debug === true,
|
||||||
|
info: yamlConfig.logging?.levels?.info === true,
|
||||||
|
warn: yamlConfig.logging?.levels?.warn === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure GadgetLog for this package
|
||||||
|
GadgetLog.consoleEnabled = yamlConfig.logging?.console?.enabled === true;
|
||||||
|
if (yamlConfig.logging?.file?.enabled === true) {
|
||||||
|
const logFileOptions = {
|
||||||
|
basePath: yamlConfig.logging.file.path
|
||||||
|
? resolvePath(yamlConfig.logging.file.path)
|
||||||
|
: path.join(INSTALL_DIR, "logs"),
|
||||||
|
name: yamlConfig.logging.file.name || "gadget-tasks",
|
||||||
|
maxWritesPerFile: yamlConfig.logging.file.maxWritesPerFile || 10000,
|
||||||
|
maxFiles: yamlConfig.logging.file.maxFiles || 10,
|
||||||
|
};
|
||||||
|
const defaultLogFile = new GadgetLogFile(logFileOptions);
|
||||||
|
defaultLogFile.open();
|
||||||
|
GadgetLog.defaultFile = defaultLogFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-enable no-process-env */
|
||||||
191
gadget-tasks/src/gadget-tasks.ts
Normal file
191
gadget-tasks/src/gadget-tasks.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
// src/gadget-tasks.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import env from "./config/env.ts";
|
||||||
|
|
||||||
|
import PlatformService from "./services/platform.ts";
|
||||||
|
import SchedulerService from "./services/scheduler.ts";
|
||||||
|
import TaskLockService from "./services/lock.ts";
|
||||||
|
|
||||||
|
import { GadgetLog, type IDroneRegistration, type IProject } from "@gadget/api";
|
||||||
|
import { GadgetProcess } from "./lib/process.ts";
|
||||||
|
|
||||||
|
const log = new GadgetLog({ name: "GadgetTasks", slug: "gadget-tasks" });
|
||||||
|
|
||||||
|
interface UserCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GadgetTasks extends GadgetProcess {
|
||||||
|
private selectedDrone: IDroneRegistration | null = null;
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return "GadgetTasks";
|
||||||
|
}
|
||||||
|
|
||||||
|
get slug(): string {
|
||||||
|
return "gadget-tasks";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.hookProcessSignals();
|
||||||
|
|
||||||
|
// 1. Acquire singleton lock via Redis
|
||||||
|
const lockAcquired = await TaskLockService.acquire();
|
||||||
|
if (!lockAcquired) {
|
||||||
|
this.log.error("another instance of gadget-tasks is already running");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get user credentials (CLI args or interactive prompt)
|
||||||
|
const credentials = await this.getUserCredentials();
|
||||||
|
|
||||||
|
// 3. Authenticate with platform
|
||||||
|
await PlatformService.start();
|
||||||
|
await PlatformService.authenticate(credentials.email, credentials.password);
|
||||||
|
this.log.info("authenticated with platform", { email: credentials.email });
|
||||||
|
|
||||||
|
// 4. Select a drone
|
||||||
|
this.selectedDrone = await this.selectDrone();
|
||||||
|
PlatformService.setSelectedDrone(this.selectedDrone);
|
||||||
|
this.log.info("drone selected", {
|
||||||
|
droneId: this.selectedDrone._id,
|
||||||
|
hostname: this.selectedDrone.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Connect Socket.IO
|
||||||
|
await PlatformService.connectSocket();
|
||||||
|
|
||||||
|
// 6. Start scheduler
|
||||||
|
await SchedulerService.start();
|
||||||
|
|
||||||
|
// 7. Fetch and schedule active tasks
|
||||||
|
await this.scheduleAllTasks();
|
||||||
|
|
||||||
|
this.log.info(`gadget-tasks v${env.pkg.version} started, ${SchedulerService.scheduledCount} tasks scheduled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<number> {
|
||||||
|
this.log.info(`gadget-tasks v${env.pkg.version} shutting down`);
|
||||||
|
|
||||||
|
await SchedulerService.stop();
|
||||||
|
await PlatformService.stop();
|
||||||
|
await TaskLockService.release();
|
||||||
|
|
||||||
|
this.log.info("shutdown complete");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query all active projects and schedule their enabled tasks.
|
||||||
|
*/
|
||||||
|
async scheduleAllTasks(): Promise<void> {
|
||||||
|
const projects = await PlatformService.getProjects();
|
||||||
|
const activeProjects = projects.filter((p) => p.status === "active");
|
||||||
|
|
||||||
|
let taskCount = 0;
|
||||||
|
for (const project of activeProjects) {
|
||||||
|
for (const task of project.tasks) {
|
||||||
|
if (!task.enabled) continue;
|
||||||
|
SchedulerService.scheduleTask(task, project as unknown as IProject);
|
||||||
|
taskCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info("tasks scheduled", {
|
||||||
|
projectCount: activeProjects.length,
|
||||||
|
taskCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user credentials from CLI args or interactive prompt.
|
||||||
|
* Same pattern as gadget-drone startup.
|
||||||
|
*/
|
||||||
|
async getUserCredentials(): Promise<UserCredentials> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const userArg = args.find((a) => a.startsWith("--user="));
|
||||||
|
const passArg = args.find((a) => a.startsWith("--password="));
|
||||||
|
|
||||||
|
if (userArg && passArg) {
|
||||||
|
return {
|
||||||
|
email: userArg.split("=")[1],
|
||||||
|
password: passArg.split("=")[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { input: inqInput, password: inqPassword } = await import("@inquirer/prompts");
|
||||||
|
return {
|
||||||
|
email: await inqInput({ message: "📧 Enter Email: " }),
|
||||||
|
password: await inqPassword({ message: "🔑 Enter Password: " }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a drone for task execution. If only one is available,
|
||||||
|
* auto-select it. Otherwise, present an interactive selection.
|
||||||
|
*/
|
||||||
|
async selectDrone(): Promise<IDroneRegistration> {
|
||||||
|
const drones = await PlatformService.getAvailableDrones();
|
||||||
|
if (drones.length === 0) {
|
||||||
|
throw new Error("No available drones. Start a gadget-drone instance first.");
|
||||||
|
}
|
||||||
|
if (drones.length === 1) {
|
||||||
|
this.log.info("auto-selecting only available drone", { droneId: drones[0]._id });
|
||||||
|
return drones[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nAvailable Drones:");
|
||||||
|
drones.forEach((d, i) => {
|
||||||
|
console.log(` ${i + 1}. ${d.hostname} (${d.workspaceId}) - ${d.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { select: inqSelect } = await import("@inquirer/prompts");
|
||||||
|
const selection = await inqSelect({
|
||||||
|
message: "Select a drone for task execution:",
|
||||||
|
choices: drones.map((d) => ({
|
||||||
|
name: `${d.hostname} (${d.workspaceId})`,
|
||||||
|
value: d,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
hookProcessSignals(): void {
|
||||||
|
process.title = this.name;
|
||||||
|
|
||||||
|
process.on("unhandledRejection", async (error: Error) => {
|
||||||
|
this.log.error("Unhandled rejection", { error, stack: error.stack });
|
||||||
|
const exitCode = await this.stop();
|
||||||
|
process.exit(exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("warning", (error) => {
|
||||||
|
if (error.name === "DeprecationWarning") return;
|
||||||
|
this.log.alert("warning", { error });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
this.log.info("SIGINT received");
|
||||||
|
const exitCode = await this.stop();
|
||||||
|
process.exit(exitCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const tasks = new GadgetTasks();
|
||||||
|
await tasks.start();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("failed to start gadget-tasks", error);
|
||||||
|
process.exit(-1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
19
gadget-tasks/src/lib/process.ts
Normal file
19
gadget-tasks/src/lib/process.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// src/lib/process.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import { GadgetComponent, GadgetLog } from "@gadget/api";
|
||||||
|
|
||||||
|
export abstract class GadgetProcess implements GadgetComponent {
|
||||||
|
protected log: GadgetLog;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new GadgetLog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
|
||||||
|
abstract start(): Promise<void>;
|
||||||
|
abstract stop(): Promise<number>;
|
||||||
|
}
|
||||||
19
gadget-tasks/src/lib/service.ts
Normal file
19
gadget-tasks/src/lib/service.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// src/lib/service.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import { GadgetComponent, GadgetLog } from "@gadget/api";
|
||||||
|
|
||||||
|
export abstract class GadgetService implements GadgetComponent {
|
||||||
|
public log: GadgetLog;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new GadgetLog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
|
||||||
|
abstract start(): Promise<void>;
|
||||||
|
abstract stop(): Promise<void>;
|
||||||
|
}
|
||||||
138
gadget-tasks/src/services/lock.ts
Normal file
138
gadget-tasks/src/services/lock.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// src/services/lock.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import os from "node:os";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import env from "../config/env.ts";
|
||||||
|
import { GadgetLog } from "@gadget/api";
|
||||||
|
|
||||||
|
const log = new GadgetLog({ name: "TaskLock", slug: "task-lock" });
|
||||||
|
|
||||||
|
const LOCK_KEY = "gadget:tasks:lock";
|
||||||
|
const LOCK_TTL_SECONDS = 60;
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
|
export interface TaskLockData {
|
||||||
|
pid: number;
|
||||||
|
hostname: string;
|
||||||
|
startedAt: number;
|
||||||
|
heartbeatAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaskLockService {
|
||||||
|
private redis: Redis | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.redis = new Redis({
|
||||||
|
host: env.redis.host,
|
||||||
|
port: env.redis.port,
|
||||||
|
password: env.redis.password || undefined,
|
||||||
|
keyPrefix: env.redis.keyPrefix,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
await this.redis.connect();
|
||||||
|
log.info("connected to Redis", {
|
||||||
|
host: env.redis.host,
|
||||||
|
port: env.redis.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to acquire the singleton lock.
|
||||||
|
* Returns true if acquired, false if another instance is running.
|
||||||
|
*/
|
||||||
|
async acquire(): Promise<boolean> {
|
||||||
|
if (!this.redis) {
|
||||||
|
throw new Error("Redis connection not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TaskLockData = {
|
||||||
|
pid: process.pid,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
heartbeatAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// SET gadget:tasks:lock <data> EX 60 NX
|
||||||
|
const result = await this.redis.set(
|
||||||
|
LOCK_KEY,
|
||||||
|
JSON.stringify(data),
|
||||||
|
"EX",
|
||||||
|
LOCK_TTL_SECONDS,
|
||||||
|
"NX",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === "OK") {
|
||||||
|
this.startHeartbeat();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock exists — check if stale
|
||||||
|
const existing = await this.redis.get(LOCK_KEY);
|
||||||
|
if (existing) {
|
||||||
|
const info = JSON.parse(existing) as TaskLockData;
|
||||||
|
const age = Date.now() - info.heartbeatAt;
|
||||||
|
if (age > LOCK_TTL_SECONDS * 1000) {
|
||||||
|
// Stale — reclaim
|
||||||
|
log.warn("stale lock detected, reclaiming", { existingPid: info.pid });
|
||||||
|
await this.redis.del(LOCK_KEY);
|
||||||
|
return this.acquire(); // retry
|
||||||
|
}
|
||||||
|
log.error("gadget-tasks is already running", {
|
||||||
|
pid: info.pid,
|
||||||
|
hostname: info.hostname,
|
||||||
|
startedAt: new Date(info.startedAt).toISOString(),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock disappeared — retry
|
||||||
|
return this.acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the lock on shutdown.
|
||||||
|
*/
|
||||||
|
async release(): Promise<void> {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
if (this.redis) {
|
||||||
|
try {
|
||||||
|
await this.redis.del(LOCK_KEY);
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.redis.quit();
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
this.redis = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start heartbeat timer to refresh lock TTL.
|
||||||
|
*/
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
this.heartbeatTimer = setInterval(async () => {
|
||||||
|
if (!this.redis) return;
|
||||||
|
try {
|
||||||
|
const existing = await this.redis.get(LOCK_KEY);
|
||||||
|
if (existing) {
|
||||||
|
const data = JSON.parse(existing) as TaskLockData;
|
||||||
|
data.heartbeatAt = Date.now();
|
||||||
|
await this.redis.set(LOCK_KEY, JSON.stringify(data), "EX", LOCK_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("heartbeat failed", { error });
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TaskLockService();
|
||||||
572
gadget-tasks/src/services/platform.ts
Normal file
572
gadget-tasks/src/services/platform.ts
Normal file
@ -0,0 +1,572 @@
|
|||||||
|
// src/services/platform.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import { GadgetService } from "../lib/service.ts";
|
||||||
|
import env from "../config/env.ts";
|
||||||
|
import {
|
||||||
|
type IChatSession,
|
||||||
|
type IProject,
|
||||||
|
type IDroneRegistration,
|
||||||
|
type IProjectTask,
|
||||||
|
type IUser,
|
||||||
|
WorkspaceMode,
|
||||||
|
type SubmitPromptCallbackData,
|
||||||
|
} from "@gadget/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API response envelope
|
||||||
|
*/
|
||||||
|
interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work order completion result
|
||||||
|
*/
|
||||||
|
interface WorkOrderResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending work order tracker — maps turnId to a Promise resolver
|
||||||
|
*/
|
||||||
|
interface PendingWorkOrder {
|
||||||
|
resolve: (value: WorkOrderResult) => void;
|
||||||
|
reject: (reason: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformService extends GadgetService {
|
||||||
|
private jwt: string | null = null;
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private selectedDrone: IDroneRegistration | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks in-flight work orders by turnId. When the server emits
|
||||||
|
* "workOrderComplete", the corresponding Promise is resolved.
|
||||||
|
*/
|
||||||
|
private pendingWorkOrders: Map<string, PendingWorkOrder> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval timer for session heartbeats sent to the drone
|
||||||
|
* via the platform. Prevents the drone's 120s heartbeat timeout.
|
||||||
|
*/
|
||||||
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return "PlatformService";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "svc:platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.log.info("started");
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject any pending work orders
|
||||||
|
for (const [turnId, pending] of this.pendingWorkOrders) {
|
||||||
|
pending.reject(new Error("PlatformService shutting down"));
|
||||||
|
}
|
||||||
|
this.pendingWorkOrders.clear();
|
||||||
|
|
||||||
|
this.log.info("stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDrone(drone: IDroneRegistration): void {
|
||||||
|
this.selectedDrone = drone;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedDrone(): IDroneRegistration | null {
|
||||||
|
return this.selectedDrone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REST API Methods ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with the gadget-code platform.
|
||||||
|
* POST /api/v1/auth/sign-in
|
||||||
|
* Returns: JWT token string
|
||||||
|
*/
|
||||||
|
async authenticate(email: string, password: string): Promise<string> {
|
||||||
|
const json = await this.apiRequest<{ token: string; user: IUser }>(
|
||||||
|
"POST",
|
||||||
|
"/auth/sign-in",
|
||||||
|
{ email, password },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!json.data?.token) {
|
||||||
|
throw new Error("Authentication response did not contain a token");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jwt = json.data.token;
|
||||||
|
this.log.info("authenticated with platform", { email });
|
||||||
|
return this.jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all projects for the authenticated user.
|
||||||
|
* GET /api/v1/projects
|
||||||
|
*/
|
||||||
|
async getProjects(): Promise<IProject[]> {
|
||||||
|
const json = await this.apiRequest<IProject[]>("GET", "/projects");
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new chat session.
|
||||||
|
* POST /api/v1/chat-sessions
|
||||||
|
*/
|
||||||
|
async createSession(opts: {
|
||||||
|
projectId: string;
|
||||||
|
providerId: string;
|
||||||
|
selectedModel: string;
|
||||||
|
mode: string;
|
||||||
|
name?: string;
|
||||||
|
}): Promise<IChatSession> {
|
||||||
|
const json = await this.apiRequest<IChatSession>("POST", "/chat-sessions", {
|
||||||
|
projectId: opts.projectId,
|
||||||
|
providerId: opts.providerId,
|
||||||
|
selectedModel: opts.selectedModel,
|
||||||
|
mode: opts.mode,
|
||||||
|
name: opts.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!json.data) {
|
||||||
|
throw new Error("Create session response did not contain data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available drone registrations.
|
||||||
|
* GET /api/v1/drone/registration
|
||||||
|
*/
|
||||||
|
async getAvailableDrones(): Promise<IDroneRegistration[]> {
|
||||||
|
const json = await this.apiRequest<IDroneRegistration[]>(
|
||||||
|
"GET",
|
||||||
|
"/drone/registration",
|
||||||
|
);
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a task's lastRun field.
|
||||||
|
* PATCH /api/v1/projects/:projectId/tasks/:taskId/lastRun
|
||||||
|
*/
|
||||||
|
async updateTaskLastRun(
|
||||||
|
projectId: string,
|
||||||
|
taskId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.apiRequest(
|
||||||
|
"PATCH",
|
||||||
|
`/projects/${projectId}/tasks/${taskId}/lastRun`,
|
||||||
|
{ sessionId },
|
||||||
|
);
|
||||||
|
this.log.info("task lastRun updated", { projectId, taskId, sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REST API Helper ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async apiRequest<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const url = `${env.platform.baseUrl}/api/v1${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (this.jwt) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (await response.json()) as ApiResponse<T>;
|
||||||
|
if (!json.success) {
|
||||||
|
const error = new Error(json.message || "API request failed");
|
||||||
|
(error as any).statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Socket.IO Methods ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the platform via Socket.IO using JWT auth.
|
||||||
|
* Mirrors the browser IDE's socket connection behavior.
|
||||||
|
*/
|
||||||
|
async connectSocket(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.jwt) {
|
||||||
|
reject(new Error("Cannot connect socket: not authenticated"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
auth: { token: this.jwt },
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 10,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
timeout: 5000,
|
||||||
|
transports: ["websocket"],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket = io(env.platform.baseUrl, options);
|
||||||
|
|
||||||
|
this.socket.on("connect_error", (err: Error) => {
|
||||||
|
this.log.error("socket connect error", { err: err.message });
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("connect", () => {
|
||||||
|
this.log.info("connected to platform via Socket.IO");
|
||||||
|
this.startHeartbeat();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for work order completion from the server
|
||||||
|
this.socket.on(
|
||||||
|
"workOrderComplete",
|
||||||
|
(turnId: string, success: boolean, message?: string) => {
|
||||||
|
this.onWorkOrderComplete(turnId, success, message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.socket.on("disconnect", (reason: string) => {
|
||||||
|
this.log.info("socket disconnected", { reason });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.io.on("reconnect", (attempt: number) => {
|
||||||
|
this.log.info("socket reconnected", { attempt });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.io.on("reconnect_attempt", (attempt: number) => {
|
||||||
|
this.log.info("socket reconnect attempt", { attempt });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.io.on("reconnect_failed", () => {
|
||||||
|
this.log.error("socket reconnection failed after max attempts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the selected drone to a session.
|
||||||
|
* Socket emit: requestSessionLock(registration, project, session, callback)
|
||||||
|
*/
|
||||||
|
async requestSessionLock(
|
||||||
|
project: IProject,
|
||||||
|
session: IChatSession,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.socket?.connected || !this.selectedDrone) {
|
||||||
|
this.log.warn("cannot request session lock: socket not connected or no drone selected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.socket!.emit(
|
||||||
|
"requestSessionLock",
|
||||||
|
this.selectedDrone,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
(success: boolean, _chatSessionId: string) => {
|
||||||
|
if (success) {
|
||||||
|
this.log.info("session lock acquired", {
|
||||||
|
droneId: this.selectedDrone!._id,
|
||||||
|
sessionId: session._id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.log.warn("session lock denied", {
|
||||||
|
droneId: this.selectedDrone!._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve(success);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the drone's workspace mode.
|
||||||
|
* Socket emit: requestWorkspaceMode(registration, project, session, mode, callback)
|
||||||
|
*/
|
||||||
|
async requestWorkspaceMode(
|
||||||
|
project: IProject,
|
||||||
|
session: IChatSession,
|
||||||
|
mode: WorkspaceMode,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.socket?.connected || !this.selectedDrone) {
|
||||||
|
this.log.warn("cannot request workspace mode: socket not connected or no drone selected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.socket!.emit(
|
||||||
|
"requestWorkspaceMode",
|
||||||
|
this.selectedDrone,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
mode,
|
||||||
|
(success: boolean, currentMode: WorkspaceMode, reason?: string) => {
|
||||||
|
if (success) {
|
||||||
|
this.log.info("workspace mode set", {
|
||||||
|
mode: currentMode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.log.warn("workspace mode request failed", {
|
||||||
|
mode,
|
||||||
|
currentMode,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve(success);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a prompt and wait for the work order to complete.
|
||||||
|
* Socket emit: submitPrompt(content, callback)
|
||||||
|
* Then waits for "workOrderComplete" server event.
|
||||||
|
*/
|
||||||
|
async submitPromptAndWait(
|
||||||
|
content: string,
|
||||||
|
): Promise<WorkOrderResult> {
|
||||||
|
if (!this.socket?.connected) {
|
||||||
|
throw new Error("Socket not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.socket!.emit(
|
||||||
|
"submitPrompt",
|
||||||
|
content,
|
||||||
|
(success: boolean, data: SubmitPromptCallbackData) => {
|
||||||
|
if (!success) {
|
||||||
|
reject(
|
||||||
|
new Error(data.message || "Prompt submission failed"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnId = data.turnId;
|
||||||
|
if (!turnId) {
|
||||||
|
reject(new Error("No turnId returned from submitPrompt"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the resolver — it will be called when workOrderComplete fires
|
||||||
|
this.pendingWorkOrders.set(turnId, { resolve, reject });
|
||||||
|
this.log.info("prompt submitted, waiting for work order completion", {
|
||||||
|
turnId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the session lock on the drone.
|
||||||
|
* Socket emit: releaseSessionLock(registration, project, session, callback)
|
||||||
|
*/
|
||||||
|
async releaseSessionLock(
|
||||||
|
project: IProject,
|
||||||
|
session: IChatSession,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.socket?.connected || !this.selectedDrone) {
|
||||||
|
this.log.warn("cannot release session lock: socket not connected or no drone selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.socket!.emit(
|
||||||
|
"releaseSessionLock",
|
||||||
|
this.selectedDrone,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
(success: boolean) => {
|
||||||
|
if (success) {
|
||||||
|
this.log.info("session lock released", {
|
||||||
|
droneId: this.selectedDrone!._id,
|
||||||
|
sessionId: session._id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.log.warn("session lock release failed", {
|
||||||
|
droneId: this.selectedDrone!._id,
|
||||||
|
sessionId: session._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task Execution ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a task through the full headless IDE pipeline:
|
||||||
|
* 1. Create ChatSession via REST API
|
||||||
|
* 2. Lock the drone to this session
|
||||||
|
* 3. Set workspace mode to Agent
|
||||||
|
* 4. Submit prompt and wait for completion
|
||||||
|
* 5. Release session lock
|
||||||
|
* 6. Update task.lastRun via dedicated PATCH endpoint
|
||||||
|
*/
|
||||||
|
async executeTask(task: IProjectTask, project: IProject): Promise<void> {
|
||||||
|
this.log.info("task execution starting", {
|
||||||
|
taskId: task._id,
|
||||||
|
taskName: task.name,
|
||||||
|
projectSlug: project.slug,
|
||||||
|
mode: task.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
let session: IChatSession | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create ChatSession via REST API
|
||||||
|
session = await this.createSession({
|
||||||
|
projectId: project._id as string,
|
||||||
|
providerId: task.provider as string,
|
||||||
|
selectedModel: task.selectedModel as string,
|
||||||
|
mode: task.mode,
|
||||||
|
name: `Task: ${task.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Lock the drone to this session
|
||||||
|
const locked = await this.requestSessionLock(project, session);
|
||||||
|
if (!locked) {
|
||||||
|
throw new Error("Failed to lock drone for task execution");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set workspace mode to Agent
|
||||||
|
const modeSet = await this.requestWorkspaceMode(
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
WorkspaceMode.Agent,
|
||||||
|
);
|
||||||
|
if (!modeSet) {
|
||||||
|
throw new Error("Failed to set drone workspace mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Submit prompt and wait for completion
|
||||||
|
const result = await this.submitPromptAndWait(task.content);
|
||||||
|
|
||||||
|
// 5. Release session lock
|
||||||
|
await this.releaseSessionLock(project, session);
|
||||||
|
|
||||||
|
// 6. Update task.lastRun via dedicated PATCH endpoint
|
||||||
|
await this.updateTaskLastRun(
|
||||||
|
project._id as string,
|
||||||
|
task._id as string,
|
||||||
|
session._id as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.log.info("task execution completed", {
|
||||||
|
taskId: task._id,
|
||||||
|
taskName: task.name,
|
||||||
|
sessionId: session._id,
|
||||||
|
success: result.success,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
this.log.error("task execution failed", {
|
||||||
|
taskId: task._id,
|
||||||
|
taskName: task.name,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to release the session lock on failure
|
||||||
|
if (session && this.socket?.connected && this.selectedDrone) {
|
||||||
|
try {
|
||||||
|
await this.releaseSessionLock(project, session);
|
||||||
|
} catch {
|
||||||
|
// Best-effort release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Work Order Completion ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the server emits "workOrderComplete".
|
||||||
|
* Resolves the corresponding pending Promise.
|
||||||
|
*/
|
||||||
|
private onWorkOrderComplete(
|
||||||
|
turnId: string,
|
||||||
|
success: boolean,
|
||||||
|
message?: string,
|
||||||
|
): void {
|
||||||
|
const pending = this.pendingWorkOrders.get(turnId);
|
||||||
|
if (pending) {
|
||||||
|
this.pendingWorkOrders.delete(turnId);
|
||||||
|
pending.resolve({ success, message });
|
||||||
|
this.log.info("work order completed", { turnId, success, message });
|
||||||
|
} else {
|
||||||
|
this.log.warn("received workOrderComplete for unknown turnId", {
|
||||||
|
turnId,
|
||||||
|
success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heartbeat ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start sending periodic session heartbeats to the drone
|
||||||
|
* via the platform. This prevents the drone's 120-second
|
||||||
|
* heartbeat timeout from firing while a task is active.
|
||||||
|
*/
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
if (this.heartbeatInterval) return; // already running
|
||||||
|
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit("sessionHeartbeat", (ack: boolean) => {
|
||||||
|
if (!ack) {
|
||||||
|
this.log.warn("sessionHeartbeat: drone did not acknowledge");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 19_000); // every 19 seconds, same as browser IDE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the heartbeat interval.
|
||||||
|
*/
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new PlatformService();
|
||||||
133
gadget-tasks/src/services/scheduler.ts
Normal file
133
gadget-tasks/src/services/scheduler.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
// src/services/scheduler.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import env from "../config/env.ts";
|
||||||
|
import { CronJob } from "cron";
|
||||||
|
import type { IProjectTask, IProject } from "@gadget/api";
|
||||||
|
import { GadgetService } from "../lib/service.ts";
|
||||||
|
import PlatformService from "./platform.ts";
|
||||||
|
|
||||||
|
class SchedulerService extends GadgetService {
|
||||||
|
private jobs: Map<string, CronJob> = new Map();
|
||||||
|
private _activeCount: number = 0;
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return "SchedulerService";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "svc:scheduler";
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeCount(): number {
|
||||||
|
return this._activeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scheduledCount(): number {
|
||||||
|
return this.jobs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.log.info("started");
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
for (const [taskId, job] of this.jobs) {
|
||||||
|
job.stop();
|
||||||
|
this.log.info("stopped cron job", { taskId });
|
||||||
|
}
|
||||||
|
this.jobs.clear();
|
||||||
|
this.log.info("stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a task by creating a CronJob.
|
||||||
|
* Key: task._id (string)
|
||||||
|
*/
|
||||||
|
scheduleTask(task: IProjectTask, project: IProject): void {
|
||||||
|
// If already scheduled, unschedule first
|
||||||
|
const taskId = String(task._id);
|
||||||
|
if (this.jobs.has(taskId)) {
|
||||||
|
this.unscheduleTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = new CronJob(
|
||||||
|
task.crontab, // cron expression
|
||||||
|
async () => {
|
||||||
|
await this.executeTask(task, project);
|
||||||
|
},
|
||||||
|
null, // onComplete
|
||||||
|
true, // start
|
||||||
|
env.timezone, // timeZone
|
||||||
|
);
|
||||||
|
|
||||||
|
this.jobs.set(taskId, job);
|
||||||
|
this.log.info("task scheduled", {
|
||||||
|
taskId,
|
||||||
|
taskName: task.name,
|
||||||
|
crontab: task.crontab,
|
||||||
|
projectSlug: project.slug,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.log.error("failed to schedule task", {
|
||||||
|
taskId,
|
||||||
|
crontab: task.crontab,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a scheduled task.
|
||||||
|
*/
|
||||||
|
unscheduleTask(taskId: string): void {
|
||||||
|
const job = this.jobs.get(taskId);
|
||||||
|
if (job) {
|
||||||
|
job.stop();
|
||||||
|
this.jobs.delete(taskId);
|
||||||
|
this.log.info("task unscheduled", { taskId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a task with concurrency control.
|
||||||
|
* Delegates to PlatformService.executeTask() which handles
|
||||||
|
* the full headless IDE pipeline.
|
||||||
|
*/
|
||||||
|
private async executeTask(task: IProjectTask, project: IProject): Promise<void> {
|
||||||
|
// Check concurrency
|
||||||
|
if (this._activeCount >= env.concurrency) {
|
||||||
|
this.log.warn("task skipped: concurrency limit reached", {
|
||||||
|
taskId: task._id,
|
||||||
|
taskName: task.name,
|
||||||
|
activeCount: this._activeCount,
|
||||||
|
maxConcurrency: env.concurrency,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeCount++;
|
||||||
|
try {
|
||||||
|
this.log.info("executing task", {
|
||||||
|
taskId: task._id,
|
||||||
|
taskName: task.name,
|
||||||
|
projectSlug: project.slug,
|
||||||
|
activeCount: this._activeCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PlatformService.executeTask(task, project);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.log.error("task execution failed", {
|
||||||
|
taskId: task._id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this._activeCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SchedulerService();
|
||||||
23
gadget-tasks/tsconfig.json
Normal file
23
gadget-tasks/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -4,5 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Gadget Code monorepo workspace root",
|
"description": "Gadget Code monorepo workspace root",
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0",
|
||||||
}
|
"scripts": {
|
||||||
|
"link:global": "pnpm --filter gadget-drone exec -- pnpm link --global && pnpm --filter gadget-tasks exec -- pnpm link --global"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
packages/ai-toolbox/.gitignore
vendored
Normal file
2
packages/ai-toolbox/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
|
||||||
38
packages/ai-toolbox/package.json
Normal file
38
packages/ai-toolbox/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@gadget/ai-toolbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gadget Code AI agent toolbox — shared tool implementations for agent processes",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"clean": "rm -rf dist/",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": ["gadget", "ai", "toolbox", "tools", "agent"],
|
||||||
|
"author": "Rob Colbert",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gadget/ai": "workspace:*",
|
||||||
|
"@gadget/api": "workspace:*",
|
||||||
|
"@mozilla/readability": "0.6.0",
|
||||||
|
"googleapis": "171.4.0",
|
||||||
|
"jsdom": "29.0.2",
|
||||||
|
"playwright": "1.59.1",
|
||||||
|
"turndown": "7.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "27.0.0",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/turndown": "5.0.5",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/chat/index.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -1,14 +1,15 @@
|
|||||||
|
// src/chat/subagent.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { formatError } from "@gadget/ai";
|
import { formatError } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
|
|
||||||
const VALID_AGENT_TYPES = ["explore", "general"] as const;
|
const VALID_AGENT_TYPES = ["explore", "general"] as const;
|
||||||
type AgentType = (typeof VALID_AGENT_TYPES)[number];
|
type AgentType = (typeof VALID_AGENT_TYPES)[number];
|
||||||
|
|
||||||
export class SubagentTool extends DroneTool {
|
export class SubagentTool extends GadgetTool {
|
||||||
private _spawnSubagent: ((agentType: string, prompt: string) => Promise<string>) | null = null;
|
private _spawnSubagent: ((agentType: string, prompt: string) => Promise<string>) | null = null;
|
||||||
|
|
||||||
setSpawner(fn: typeof this._spawnSubagent): void {
|
setSpawner(fn: typeof this._spawnSubagent): void {
|
||||||
11
packages/ai-toolbox/src/index.ts
Normal file
11
packages/ai-toolbox/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// src/index.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
export { AiToolbox, type GadgetToolboxEnvironment } from "./toolbox.ts";
|
||||||
|
export { GadgetTool } from "./tool.ts";
|
||||||
|
export * from "./chat/index.ts";
|
||||||
|
export * from "./system/index.ts";
|
||||||
|
export * from "./network/index.ts";
|
||||||
|
export * from "./plan/index.ts";
|
||||||
|
export * from "./project/index.ts";
|
||||||
@ -1,12 +1,13 @@
|
|||||||
|
// src/network/fetch-url.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { asPositiveInteger, getCacheDir, toolError } from "../system/common.ts";
|
import { asPositiveInteger, getCacheDir, toolError } from "../system/common.ts";
|
||||||
import { WebFetcher } from "./web-fetcher.ts";
|
import { WebFetcher } from "./web-fetcher.ts";
|
||||||
|
|
||||||
export class FetchUrlTool extends DroneTool {
|
export class FetchUrlTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "fetch_url";
|
return "fetch_url";
|
||||||
}
|
}
|
||||||
@ -19,7 +20,7 @@ export class FetchUrlTool extends DroneTool {
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the drone .gadget/cache directory, and return an optional line range like file_read.",
|
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the .gadget/cache directory, and return an optional line range like file_read.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@ -77,7 +78,7 @@ export class FetchUrlTool extends DroneTool {
|
|||||||
if (!cacheDir) {
|
if (!cacheDir) {
|
||||||
return toolError({
|
return toolError({
|
||||||
code: "OPERATION_NOT_ALLOWED",
|
code: "OPERATION_NOT_ALLOWED",
|
||||||
message: "No drone cache directory is configured for fetch_url.",
|
message: "No cache directory is configured for fetch_url.",
|
||||||
recoveryHint: "Run this tool during an active Agent work order after workspace initialization.",
|
recoveryHint: "Run this tool during an active Agent work order after workspace initialization.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/network/index.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/network/search-google.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,7 +6,7 @@ import { google } from "googleapis";
|
|||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { formatError } from "@gadget/ai";
|
import { formatError } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
|
|
||||||
export interface ISearchResult {
|
export interface ISearchResult {
|
||||||
title: string;
|
title: string;
|
||||||
@ -26,7 +27,7 @@ export interface ISearchOptions {
|
|||||||
start?: number;
|
start?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoogleSearchTool extends DroneTool {
|
export class GoogleSearchTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "search_google";
|
return "search_google";
|
||||||
}
|
}
|
||||||
@ -140,7 +141,7 @@ export class GoogleSearchTool extends DroneTool {
|
|||||||
const engineId = this.toolbox.env.services?.google?.cse?.engineId;
|
const engineId = this.toolbox.env.services?.google?.cse?.engineId;
|
||||||
|
|
||||||
if (!apiKey || !engineId) {
|
if (!apiKey || !engineId) {
|
||||||
throw new Error("Google CSE credentials not configured in drone environment");
|
throw new Error("Google CSE credentials not configured in environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
const customSearch = google.customsearch({
|
const customSearch = google.customsearch({
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/network/web-fetcher.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -29,15 +30,8 @@ export class WebFetcher {
|
|||||||
hr: "---",
|
hr: "---",
|
||||||
});
|
});
|
||||||
this.turndown.remove([
|
this.turndown.remove([
|
||||||
"script",
|
"script", "style", "noscript", "nav", "footer", "header",
|
||||||
"style",
|
"button", "input", "form",
|
||||||
"noscript",
|
|
||||||
"nav",
|
|
||||||
"footer",
|
|
||||||
"header",
|
|
||||||
"button",
|
|
||||||
"input",
|
|
||||||
"form",
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/plan/common.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -1,14 +1,15 @@
|
|||||||
|
// src/plan/edit.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { toolError } from "../system/common.ts";
|
import { toolError } from "../system/common.ts";
|
||||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||||
|
|
||||||
export class PlanFileEditTool extends DroneTool {
|
export class PlanFileEditTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "plan_file_edit";
|
return "plan_file_edit";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/plan/index.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/plan/list.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,11 +6,11 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { toolError } from "../system/common.ts";
|
import { toolError } from "../system/common.ts";
|
||||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||||
|
|
||||||
export class PlanListTool extends DroneTool {
|
export class PlanListTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "plan_list";
|
return "plan_list";
|
||||||
}
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
|
// src/plan/read.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import {
|
import {
|
||||||
asPositiveInteger,
|
asPositiveInteger,
|
||||||
formatNumberedLines,
|
formatNumberedLines,
|
||||||
@ -13,7 +14,7 @@ import {
|
|||||||
} from "../system/common.ts";
|
} from "../system/common.ts";
|
||||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||||
|
|
||||||
export class PlanFileReadTool extends DroneTool {
|
export class PlanFileReadTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "plan_file_read";
|
return "plan_file_read";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/plan/write.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,11 +6,11 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { toolError } from "../system/common.ts";
|
import { toolError } from "../system/common.ts";
|
||||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||||
|
|
||||||
export class PlanFileWriteTool extends DroneTool {
|
export class PlanFileWriteTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "plan_file_write";
|
return "plan_file_write";
|
||||||
}
|
}
|
||||||
6
packages/ai-toolbox/src/project/index.ts
Normal file
6
packages/ai-toolbox/src/project/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// src/project/index.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
export { ListSkillsTool } from "./list-skills.ts";
|
||||||
|
export { ReadSkillTool } from "./read-skill.ts";
|
||||||
63
packages/ai-toolbox/src/project/list-skills.ts
Normal file
63
packages/ai-toolbox/src/project/list-skills.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// src/project/list-skills.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import type { IAiLogger, IToolArguments } from "@gadget/ai";
|
||||||
|
import { GadgetTool } from "../tool.ts";
|
||||||
|
|
||||||
|
export class ListSkillsTool extends GadgetTool {
|
||||||
|
get name(): string {
|
||||||
|
return "list_skills";
|
||||||
|
}
|
||||||
|
|
||||||
|
get category(): string {
|
||||||
|
return "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
public definition = {
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: this.name,
|
||||||
|
description:
|
||||||
|
"List all skills defined on the current project that are available for the current mode. Skills contain project-specific knowledge and instructions that can help you work more effectively. Use read_skill(index) to read a skill's full content.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public async execute(_args: IToolArguments, _logger: IAiLogger): Promise<string> {
|
||||||
|
const project = this.toolbox.env.project;
|
||||||
|
if (!project) {
|
||||||
|
return "No project is configured for this session.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = this.toolbox.env.chatSessionMode;
|
||||||
|
const skills = project.skills.filter(
|
||||||
|
(s) => mode && s.modes.includes(mode),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return mode
|
||||||
|
? `Project Skills (0 available in ${mode} mode)\n\nNo skills are defined for this project in ${mode} mode.`
|
||||||
|
: "No skills are defined for this project.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`Project Skills (${skills.length} available in ${mode} mode):`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < skills.length; i++) {
|
||||||
|
const skill = skills[i];
|
||||||
|
const preview = skill.content.length > 80
|
||||||
|
? skill.content.slice(0, 80).trim() + "..."
|
||||||
|
: skill.content.trim();
|
||||||
|
lines.push(`[#${i}] ${skill.name} — ${preview}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/ai-toolbox/src/project/read-skill.ts
Normal file
65
packages/ai-toolbox/src/project/read-skill.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// src/project/read-skill.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
import type { IAiLogger, IToolArguments } from "@gadget/ai";
|
||||||
|
import { GadgetTool } from "../tool.ts";
|
||||||
|
|
||||||
|
export class ReadSkillTool extends GadgetTool {
|
||||||
|
get name(): string {
|
||||||
|
return "read_skill";
|
||||||
|
}
|
||||||
|
|
||||||
|
get category(): string {
|
||||||
|
return "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
public definition = {
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: this.name,
|
||||||
|
description:
|
||||||
|
"Read the full content of a project skill by its index number. Use list_skills() first to see available skills and their indices.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
index: {
|
||||||
|
type: "number",
|
||||||
|
description: "The skill index from list_skills()",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["index"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public async execute(args: IToolArguments, _logger: IAiLogger): Promise<string> {
|
||||||
|
const project = this.toolbox.env.project;
|
||||||
|
if (!project) {
|
||||||
|
return "No project is configured for this session.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = typeof args.index === "number" ? args.index : parseInt(String(args.index), 10);
|
||||||
|
if (isNaN(index)) {
|
||||||
|
return "Invalid index. Provide a numeric index from list_skills().";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = this.toolbox.env.chatSessionMode;
|
||||||
|
const skills = project.skills.filter(
|
||||||
|
(s) => mode && s.modes.includes(mode),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index < 0 || index >= skills.length) {
|
||||||
|
return `Skill index ${index} not found. Use list_skills() to see available skills.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skill = skills[index];
|
||||||
|
const lines: string[] = [
|
||||||
|
`Skill: ${skill.name} (${mode} mode)`,
|
||||||
|
"",
|
||||||
|
skill.content,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/common.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -8,30 +9,10 @@ import { formatError, type IToolError } from "@gadget/ai";
|
|||||||
import type { AiToolbox } from "../toolbox.ts";
|
import type { AiToolbox } from "../toolbox.ts";
|
||||||
|
|
||||||
export const BINARY_EXTENSIONS = new Set([
|
export const BINARY_EXTENSIONS = new Set([
|
||||||
".o",
|
".o", ".obj", ".a", ".lib", ".so", ".dll", ".exe", ".bin",
|
||||||
".obj",
|
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
|
||||||
".a",
|
".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".wasm",
|
||||||
".lib",
|
".pyc", ".class",
|
||||||
".so",
|
|
||||||
".dll",
|
|
||||||
".exe",
|
|
||||||
".bin",
|
|
||||||
".png",
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".gif",
|
|
||||||
".bmp",
|
|
||||||
".ico",
|
|
||||||
".webp",
|
|
||||||
".pdf",
|
|
||||||
".zip",
|
|
||||||
".tar",
|
|
||||||
".gz",
|
|
||||||
".bz2",
|
|
||||||
".7z",
|
|
||||||
".wasm",
|
|
||||||
".pyc",
|
|
||||||
".class",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface ResolvedProjectPath {
|
export interface ResolvedProjectPath {
|
||||||
@ -1,13 +1,14 @@
|
|||||||
|
// src/system/edit.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { resolveProjectPath, toolError } from "./common.ts";
|
import { resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
export class FileEditTool extends DroneTool {
|
export class FileEditTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "file_edit";
|
return "file_edit";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/glob.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,12 +6,12 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
const MAX_RESULTS = 1000;
|
const MAX_RESULTS = 1000;
|
||||||
|
|
||||||
export class GlobTool extends DroneTool {
|
export class GlobTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "glob";
|
return "glob";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/grep.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,17 +6,16 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
const MAX_MATCHES = 500;
|
const MAX_MATCHES = 500;
|
||||||
const MAX_FILE_SIZE = 1024 * 1024;
|
const MAX_FILE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
// TODO: Consider broadening this list or making it user-configurable
|
|
||||||
// Currently limited to common source/doc extensions for performance.
|
// Currently limited to common source/doc extensions for performance.
|
||||||
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
|
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
|
||||||
|
|
||||||
export class GrepTool extends DroneTool {
|
export class GrepTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "grep";
|
return "grep";
|
||||||
}
|
}
|
||||||
11
packages/ai-toolbox/src/system/index.ts
Normal file
11
packages/ai-toolbox/src/system/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// src/system/index.ts
|
||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
|
export { FileReadTool } from "./read.ts";
|
||||||
|
export { FileWriteTool } from "./write.ts";
|
||||||
|
export { FileEditTool } from "./edit.ts";
|
||||||
|
export { ShellExecTool } from "./shell.ts";
|
||||||
|
export { ListTool } from "./list.ts";
|
||||||
|
export { GrepTool } from "./grep.ts";
|
||||||
|
export { GlobTool } from "./glob.ts";
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/list.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,12 +6,12 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
const MAX_ENTRIES = 1000;
|
const MAX_ENTRIES = 1000;
|
||||||
|
|
||||||
export class ListTool extends DroneTool {
|
export class ListTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "list";
|
return "list";
|
||||||
}
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
|
// src/system/read.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import {
|
import {
|
||||||
asPositiveInteger,
|
asPositiveInteger,
|
||||||
formatNumberedLines,
|
formatNumberedLines,
|
||||||
@ -13,7 +14,7 @@ import {
|
|||||||
toolError,
|
toolError,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
|
|
||||||
export class FileReadTool extends DroneTool {
|
export class FileReadTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "file_read";
|
return "file_read";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/shell.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -6,14 +7,14 @@ import path from "node:path";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const DEFAULT_TIMEOUT = 30_000;
|
const DEFAULT_TIMEOUT = 30_000;
|
||||||
const MAX_TIMEOUT = 120_000;
|
const MAX_TIMEOUT = 120_000;
|
||||||
|
|
||||||
export class ShellExecTool extends DroneTool {
|
export class ShellExecTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "shell_exec";
|
return "shell_exec";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/system/write.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -5,10 +6,10 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||||
import { DroneTool } from "../tool.ts";
|
import { GadgetTool } from "../tool.ts";
|
||||||
import { resolveProjectPath, toolError } from "./common.ts";
|
import { resolveProjectPath, toolError } from "./common.ts";
|
||||||
|
|
||||||
export class FileWriteTool extends DroneTool {
|
export class FileWriteTool extends GadgetTool {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "file_write";
|
return "file_write";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// src/tool.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ import type {
|
|||||||
} from "@gadget/ai";
|
} from "@gadget/ai";
|
||||||
import type { AiToolbox } from "./toolbox.ts";
|
import type { AiToolbox } from "./toolbox.ts";
|
||||||
|
|
||||||
export abstract class DroneTool implements IAiTool {
|
export abstract class GadgetTool implements IAiTool {
|
||||||
protected _toolbox: AiToolbox;
|
protected _toolbox: AiToolbox;
|
||||||
|
|
||||||
constructor(toolbox: AiToolbox) {
|
constructor(toolbox: AiToolbox) {
|
||||||
@ -1,9 +1,11 @@
|
|||||||
|
// src/toolbox.ts
|
||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IAiTool } from "@gadget/ai";
|
import type { IAiTool } from "@gadget/ai";
|
||||||
|
import type { IProject, ChatSessionMode } from "@gadget/api";
|
||||||
|
|
||||||
export interface DroneToolboxEnvironment {
|
export interface GadgetToolboxEnvironment {
|
||||||
NODE_ENV: string;
|
NODE_ENV: string;
|
||||||
services?: {
|
services?: {
|
||||||
google?: {
|
google?: {
|
||||||
@ -18,28 +20,35 @@ export interface DroneToolboxEnvironment {
|
|||||||
projectDir?: string;
|
projectDir?: string;
|
||||||
cacheDir?: string;
|
cacheDir?: string;
|
||||||
};
|
};
|
||||||
|
project?: IProject;
|
||||||
|
chatSessionMode?: ChatSessionMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolMap = Map<string, IAiTool>;
|
export type ToolMap = Map<string, IAiTool>;
|
||||||
export type ToolSet = Set<IAiTool>;
|
export type ToolSet = Set<IAiTool>;
|
||||||
|
|
||||||
export class AiToolbox {
|
export class AiToolbox {
|
||||||
private _env: DroneToolboxEnvironment;
|
private _env: GadgetToolboxEnvironment;
|
||||||
private tools: ToolMap = new Map<string, IAiTool>();
|
private tools: ToolMap = new Map<string, IAiTool>();
|
||||||
private modeSets: Map<string, ToolSet> = new Map<string, Set<IAiTool>>();
|
private modeSets: Map<string, ToolSet> = new Map<string, Set<IAiTool>>();
|
||||||
|
|
||||||
constructor(env: DroneToolboxEnvironment) {
|
constructor(env: GadgetToolboxEnvironment) {
|
||||||
this._env = env;
|
this._env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
get env(): DroneToolboxEnvironment {
|
get env(): GadgetToolboxEnvironment {
|
||||||
return this._env;
|
return this._env;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWorkspace(workspace: NonNullable<DroneToolboxEnvironment["workspace"]>): void {
|
updateWorkspace(workspace: NonNullable<GadgetToolboxEnvironment["workspace"]>): void {
|
||||||
this._env.workspace = workspace;
|
this._env.workspace = workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProjectContext(project: IProject, mode: ChatSessionMode): void {
|
||||||
|
this._env.project = project;
|
||||||
|
this._env.chatSessionMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
register(tool: IAiTool, modes?: string[]): void {
|
register(tool: IAiTool, modes?: string[]): void {
|
||||||
if (this.tools.has(tool.name)) {
|
if (this.tools.has(tool.name)) {
|
||||||
throw new Error(`tool already registered: ${tool.name}`);
|
throw new Error(`tool already registered: ${tool.name}`);
|
||||||
24
packages/ai-toolbox/tsconfig.json
Normal file
24
packages/ai-toolbox/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"typeRoots": ["node_modules/@types"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@ -3,8 +3,11 @@
|
|||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import type { IUser } from "./user.js";
|
import type { IUser } from "./user.js";
|
||||||
|
import type { IAiProvider } from "./ai-provider.ts";
|
||||||
|
|
||||||
import { GadgetId } from "../lib/gadget-id.ts";
|
import { GadgetId } from "../lib/gadget-id.ts";
|
||||||
import { HydratedDocument } from "mongoose";
|
import { HydratedDocument } from "mongoose";
|
||||||
|
import { ChatSessionMode, IChatSession } from "./chat-session.ts";
|
||||||
|
|
||||||
export enum ProjectStatus {
|
export enum ProjectStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
@ -12,6 +15,25 @@ export enum ProjectStatus {
|
|||||||
Archived = "archived",
|
Archived = "archived",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectSkill {
|
||||||
|
_id: GadgetId;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
modes: ChatSessionMode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectTask {
|
||||||
|
_id: GadgetId;
|
||||||
|
name: string;
|
||||||
|
provider: IAiProvider | GadgetId;
|
||||||
|
selectedModel: string;
|
||||||
|
mode: ChatSessionMode;
|
||||||
|
crontab: string;
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastRun: IChatSession | GadgetId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IProject {
|
export interface IProject {
|
||||||
_id: GadgetId;
|
_id: GadgetId;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -20,6 +42,10 @@ export interface IProject {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
system?: string;
|
||||||
|
skills: IProjectSkill[];
|
||||||
|
tasks: IProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectDocument = HydratedDocument<IProject>;
|
export type ProjectDocument = HydratedDocument<IProject>;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import type { GadgetCodeConfig, GadgetDroneConfig } from "./types.js";
|
import type { GadgetCodeConfig, GadgetDroneConfig, GadgetTasksConfig } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitute environment variables in YAML values.
|
* Substitute environment variables in YAML values.
|
||||||
@ -121,6 +121,13 @@ export function loadGadgetDroneConfig(): GadgetDroneConfig {
|
|||||||
return loadYamlConfig<GadgetDroneConfig>("gadget-drone.yaml");
|
return loadYamlConfig<GadgetDroneConfig>("gadget-drone.yaml");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Gadget Tasks configuration
|
||||||
|
*/
|
||||||
|
export function loadGadgetTasksConfig(): GadgetTasksConfig {
|
||||||
|
return loadYamlConfig<GadgetTasksConfig>("gadget-tasks.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a path that may contain ~ for home directory
|
* Resolve a path that may contain ~ for home directory
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -150,6 +150,40 @@ export interface GadgetDroneConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gadget Tasks Worker Configuration
|
||||||
|
*/
|
||||||
|
export interface GadgetTasksConfig {
|
||||||
|
timezone?: string;
|
||||||
|
platform: {
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
redis: {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
password?: string;
|
||||||
|
keyPrefix?: string;
|
||||||
|
};
|
||||||
|
concurrency?: number; // max concurrent tasks, default 1 (sequential)
|
||||||
|
logging?: {
|
||||||
|
console?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
file?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
path?: string;
|
||||||
|
name?: string;
|
||||||
|
maxWritesPerFile?: number;
|
||||||
|
maxFiles?: number;
|
||||||
|
};
|
||||||
|
levels?: {
|
||||||
|
debug?: boolean;
|
||||||
|
info?: boolean;
|
||||||
|
warn?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration search result
|
* Configuration search result
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,180 +0,0 @@
|
|||||||
# User Mode MVP — Continuation Prompt
|
|
||||||
|
|
||||||
## Session Context
|
|
||||||
|
|
||||||
**Session Date:** May 13, 2026
|
|
||||||
**Session ID:** 5hQhhRHYdL_e10Zw7EDLv
|
|
||||||
**Branch:** `feature/user-mode` (latest: `7dca4b5`)
|
|
||||||
**Status:** MVP Complete ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Was Accomplished
|
|
||||||
|
|
||||||
### 1. ACE Editor Integration Crisis → CodeMirror Migration
|
|
||||||
|
|
||||||
**Initial Problem:** The previous agent's ACE editor integration was crashing the entire ChatSessionView. After extensive debugging, we discovered:
|
|
||||||
|
|
||||||
- `react-ace` v14 ships **CommonJS-only** (`"main": "lib/index.js"`, no `"module"` or `"exports"` field)
|
|
||||||
- Vite's ESM-first dev server cannot properly resolve CJS default exports
|
|
||||||
- Every workaround failed: CJS interop hacks (`.default || module`), `?url` imports, `setModuleUrl()`, `optimizeDeps.include`
|
|
||||||
- This is a **fundamental architecture mismatch**, not a configuration issue
|
|
||||||
|
|
||||||
**Solution:** Migrated to `@uiw/react-codemirror` v4.25.9
|
|
||||||
|
|
||||||
- Ships dual ESM+CJS with proper `exports` map
|
|
||||||
- Works with Vite out of the box (zero hacks)
|
|
||||||
- React 19 compatible (`>=17.0.0` peer dep)
|
|
||||||
- ~124KB gzipped (vs Monaco's ~5MB)
|
|
||||||
|
|
||||||
**Files Changed:**
|
|
||||||
- `frontend/package.json` — removed `ace-builds`, `react-ace`; added `@uiw/react-codemirror` + 16 `@codemirror/lang-*` packages + theme
|
|
||||||
- `frontend/src/components/EditorPanel.tsx` — deleted 108 lines of ACE boilerplate, replaced with ~30 lines of clean CodeMirror setup
|
|
||||||
- `frontend/src/types/vite.d.ts` — **deleted** (only needed for ACE `?url` import types)
|
|
||||||
- `frontend/vite.config.ts` — removed `optimizeDeps.include` for ACE
|
|
||||||
|
|
||||||
### 2. Editor Height Constraint Fix
|
|
||||||
|
|
||||||
**Problem:** The CodeMirror editor was overflowing its container, extending off the bottom of the screen, and not scrolling properly.
|
|
||||||
|
|
||||||
**Root Cause:** `@uiw/react-codemirror` renders a wrapper `<div>` between `.cm-editor-container` and `.cm-editor` that doesn't inherit flex height constraints by default.
|
|
||||||
|
|
||||||
**Solution:** Added CSS rules that explicitly constrain **every level** of the CodeMirror DOM tree:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.cm-editor-container {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container > div { /* The wrapper @uiw/react-codemirror renders */
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container .cm-editor {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container .cm-scroller {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto; /* ← Only this scrolls */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Changed:**
|
|
||||||
- `frontend/src/index.css` — added comprehensive flex layout constraints for CodeMirror
|
|
||||||
|
|
||||||
### 3. Error Boundary Protection
|
|
||||||
|
|
||||||
- Added `ErrorBoundary` component wrapping `<EditorPanel>` in `ChatSessionView`
|
|
||||||
- Catches render errors and displays fallback UI instead of crashing the entire app
|
|
||||||
|
|
||||||
### 4. Heartbeat Worker Verification
|
|
||||||
|
|
||||||
- Verified the IDE→gadget-drone heartbeat worker (`src/workers/heartbeat.worker.ts`) remains completely intact
|
|
||||||
- No changes to heartbeat logic, socket.ts, or session management
|
|
||||||
- Production build includes the heartbeat worker (inlined as base64 data URL)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Technical Learnings
|
|
||||||
|
|
||||||
### CJS/ESM Interop with Vite
|
|
||||||
|
|
||||||
1. **Vite is ESM-first** — CJS modules are pre-bundle-converted, but default exports from CJS (`exports.default = value`) get wrapped as `{ default: value, __esModule: true }`
|
|
||||||
2. **Named exports work fine** — the issue is specifically with `export default` from CJS packages
|
|
||||||
3. **Rolldown (Vite 8's bundler) handles this better than Rollup**, but the underlying CJS interop issue remains for packages without proper ESM entry points
|
|
||||||
4. **Best practice:** Always prefer packages with `"module"` or `"exports"` fields pointing to ESM builds when using Vite
|
|
||||||
|
|
||||||
### Flex Layout Height Constraints
|
|
||||||
|
|
||||||
1. **Every level in the flex chain must have `min-height: 0`** — without this, flex items can grow beyond their allocated space
|
|
||||||
2. **`flex: 1` alone is not enough** — need `min-height: 0` or `overflow: hidden` to prevent overflow
|
|
||||||
3. **Third-party components often break flex layouts** — they render wrapper divs that don't inherit parent constraints
|
|
||||||
4. **The fix pattern:** Use CSS to explicitly target every rendered level and apply `flex: 1`, `min-height: 0`, `overflow: hidden` appropriately
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Remaining Steps / Next Session
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
|
|
||||||
1. **Test all supported file types** — verify syntax highlighting works for:
|
|
||||||
- JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML
|
|
||||||
- Unsupported types (Ruby, Sass, Dockerfile, Makefile, Shell) should fall back to plain text
|
|
||||||
|
|
||||||
2. **Verify read-only mode** — confirm Agent mode properly prevents editing (we tested this, but more thorough testing recommended)
|
|
||||||
|
|
||||||
3. **Test file save/load cycle** — ensure Ctrl+S works, dirty state tracking is correct, and success/error states display properly
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
|
|
||||||
4. **Add missing language support** (optional, if needed):
|
|
||||||
- Ruby: `@codemirror/legacy-modes/mode/ruby` (legacy mode, not as good as CM6 native)
|
|
||||||
- Sass/SCSS: `@codemirror/lang-sass` (if available)
|
|
||||||
- Shell/Dockerfile/Makefile: Plain text is probably fine for now
|
|
||||||
|
|
||||||
5. **Autocompletion** — CodeMirror 6 supports autocompletion via `@codemirror/autocomplete` and language-specific completion extensions. Could add basic keyword completion for supported languages.
|
|
||||||
|
|
||||||
6. **Theme customization** — The `tomorrow-night-blue` theme is close to ACE's "tomorrow" but not identical. Could customize colors to match the rest of the UI better.
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
|
|
||||||
7. **Performance optimization** — For very large files (>10k lines), consider:
|
|
||||||
- CodeMirror's built-in line wrapping limits
|
|
||||||
- Virtual scrolling (already built into CM6)
|
|
||||||
- Lazy-loading language extensions
|
|
||||||
|
|
||||||
8. **Editor preferences** — Allow users to configure:
|
|
||||||
- Font size
|
|
||||||
- Tab size
|
|
||||||
- Word wrap
|
|
||||||
- Minimap (CodeMirror has a minimap extension)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Continuation Prompt for Next Session
|
|
||||||
|
|
||||||
```
|
|
||||||
You are continuing work on the Gadget Code project's User Mode MVP. The ACE editor has been successfully migrated to @uiw/react-codemirror, and the editor now properly fits within its flex container and scrolls correctly.
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
- Branch: `feature/user-mode` (latest commit: check `git log --oneline -1`)
|
|
||||||
- Editor: @uiw/react-codemirror v4.25 with tomorrow-night-blue theme
|
|
||||||
- Supported languages: JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML
|
|
||||||
- Editor properly constrained in flex layout, scrolls internally
|
|
||||||
- ErrorBoundary wraps EditorPanel
|
|
||||||
- Heartbeat worker intact
|
|
||||||
|
|
||||||
Your task: [INSERT TASK HERE]
|
|
||||||
|
|
||||||
Key files:
|
|
||||||
- `gadget-code/frontend/src/components/EditorPanel.tsx` — Editor component
|
|
||||||
- `gadget-code/frontend/src/index.css` — Flex layout constraints for CodeMirror
|
|
||||||
- `gadget-code/frontend/src/pages/ChatSessionView.tsx` — Parent view with ErrorBoundary
|
|
||||||
- `gadget-code/frontend/package.json` — Dependencies
|
|
||||||
|
|
||||||
Important patterns:
|
|
||||||
- Use `getLanguageExtensions()` to map file extensions to CodeMirror language extensions
|
|
||||||
- Flex layout chain: #root → main → ChatSessionView → EditorPanel → .cm-editor-container → wrapper div → .cm-editor → .cm-scroller (only this scrolls)
|
|
||||||
- All language extensions imported from `@codemirror/lang-*` packages
|
|
||||||
- Theme: `tomorrowNightBlue` from `@uiw/codemirror-theme-tomorrow-night-blue`
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria for User Mode (MVP)
|
|
||||||
|
|
||||||
- ✅ User can open files in the editor
|
|
||||||
- ✅ User can edit files in User mode
|
|
||||||
- ✅ User can save files (Ctrl+S or button)
|
|
||||||
- ✅ Agent mode makes editor read-only
|
|
||||||
- ✅ Editor stays within its allocated space
|
|
||||||
- ✅ Editor scrolls properly when content overflows
|
|
||||||
- ✅ Syntax highlighting works for supported languages
|
|
||||||
- ✅ ErrorBoundary catches editor crashes
|
|
||||||
- ✅ Heartbeat worker continues functioning (no regression)
|
|
||||||
|
|
||||||
**MVP Status: COMPLETE** ✅
|
|
||||||
|
|
||||||
All success criteria met. The User Mode MVP is ready for production testing.
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
# Session Summary: User Mode Editor Integration
|
|
||||||
**Date:** May 13, 2026
|
|
||||||
**Session:** Fix ACE Editor Integration → Migrate to CodeMirror → User Mode MVP Complete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What We Accomplished
|
|
||||||
|
|
||||||
### 1. ACE Editor Integration Failed (Root Cause Analysis)
|
|
||||||
|
|
||||||
**Attempted:** Integrate `react-ace` v14.0.1 with Vite + React 19
|
|
||||||
|
|
||||||
**Problem:** `react-ace` v14 ships **CommonJS-only** (`"main": "lib/index.js"`, no `"module"` or `"exports"` field). Vite's ESM-first dev server cannot properly resolve its default export, causing "Element type is invalid: got object" on every render.
|
|
||||||
|
|
||||||
**Workarounds attempted (all failed):**
|
|
||||||
- CJS interop hack: `import * as ReactAceModule; const Ace = ReactAceModule.default || ReactAceModule`
|
|
||||||
- `optimizeDeps.include: ['react-ace', 'ace-builds']` in vite.config.ts
|
|
||||||
- 56 lines of `?url` imports + `ace.config.setModuleUrl()` registration
|
|
||||||
- Namespace imports with fallback extraction
|
|
||||||
|
|
||||||
**Conclusion:** This is a **fundamental architecture mismatch**, not a configuration issue. `react-ace` is incompatible with Vite's ESM-first model. Every workaround is fragile and breaks on Vite updates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Migrated to @uiw/react-codemirror (Success)
|
|
||||||
|
|
||||||
**Decision:** Switch to `@uiw/react-codemirror` v4.25.9 — dual ESM+CJS package with proper `exports` map, React 19 support (`>=17.0.0` peer dep), works with Vite out of the box.
|
|
||||||
|
|
||||||
**Changes made:**
|
|
||||||
- Removed `ace-builds`, `react-ace` from `frontend/package.json`
|
|
||||||
- Added `@uiw/react-codemirror` + 16 `@codemirror/lang-*` packages + `@uiw/codemirror-theme-tomorrow-night-blue`
|
|
||||||
- Added `@replit/codemirror-lang-csharp` for C# support
|
|
||||||
- Rewrote `EditorPanel.tsx`: deleted 108 lines of ACE boilerplate, replaced with ~30 lines of clean CodeMirror setup
|
|
||||||
- Deleted `frontend/src/types/vite.d.ts` (only needed for ACE `?url` imports)
|
|
||||||
- Removed `optimizeDeps.include` from `vite.config.ts` (not needed for CM)
|
|
||||||
- Added CodeMirror flex layout CSS to `index.css`
|
|
||||||
|
|
||||||
**Supported languages:** JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML. Unsupported types fall back to plain text.
|
|
||||||
|
|
||||||
**Bundle size:** ~124KB gzipped (CodeMirror 6 core) vs ~56KB for ACE, but ~40x smaller than Monaco's ~5MB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Fixed Flex Layout Height Constraint Issue
|
|
||||||
|
|
||||||
**Problem:** Editor overflowed its container and didn't stay in allocated area.
|
|
||||||
|
|
||||||
**Root cause:** `@uiw/react-codemirror` renders a wrapper `<div>` between `.cm-editor-container` and `.cm-editor` that doesn't inherit flex constraints by default.
|
|
||||||
|
|
||||||
**Solution:** Added CSS rules that constrain **every level** of the CodeMirror DOM tree:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.cm-editor-container {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container > div { /* The wrapper @uiw/react-codemirror renders */
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container .cm-editor {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cm-editor-container .cm-scroller {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto; /* ← Only this scrolls */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Layout chain:**
|
|
||||||
```
|
|
||||||
.cm-editor-container (flex-1, min-h-0)
|
|
||||||
└─ wrapper div (flex:1, min-h-0, overflow:hidden)
|
|
||||||
└─ .cm-editor (flex:1, min-h-0, overflow:hidden)
|
|
||||||
└─ .cm-scroller (min-h-0, overflow:auto) ← only this scrolls
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Technical Learnings
|
|
||||||
|
|
||||||
### 1. CJS/ESM Interop in Vite is Fragile
|
|
||||||
|
|
||||||
When a package ships CJS-only with `export default`, Vite's dev server pre-bundles it as a namespace object where the default export is nested under `.default`. This causes "Element type is invalid: got object" errors when importing default exports from CJS-only React components.
|
|
||||||
|
|
||||||
**Rule:** For React components in Vite projects, prefer packages that ship dual ESM+CJS with proper `exports` maps. Avoid CJS-only packages — every workaround is fragile.
|
|
||||||
|
|
||||||
### 2. Flex Layout Height Constraints Must Be Explicit at Every Level
|
|
||||||
|
|
||||||
For a flex item to properly fill its allocated space:
|
|
||||||
1. Every ancestor in the flex chain must have `flex: 1` or explicit height
|
|
||||||
2. Every ancestor must have `min-height: 0` (or `min-width: 0` for horizontal flex)
|
|
||||||
3. For third-party components that render wrapper divs, add explicit CSS rules targeting those wrappers
|
|
||||||
4. Only the innermost scrollable element should have `overflow: auto`; all ancestors should have `overflow: hidden`
|
|
||||||
|
|
||||||
**Pattern:**
|
|
||||||
```css
|
|
||||||
.container {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.container > div { /* Wrapper div from third-party component */
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.container .scrollable {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto; /* Only this scrolls */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Heartbeat Worker Unaffected
|
|
||||||
|
|
||||||
The IDE heartbeat worker (`src/workers/heartbeat.worker.ts`) was completely untouched by the editor migration. It continues to run in a Web Worker to avoid browser tab throttling, sending session heartbeats every 19 seconds to keep the drone connection alive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `frontend/package.json` | Replaced `ace-builds`, `react-ace` with `@uiw/react-codemirror` + language packages |
|
|
||||||
| `frontend/src/components/EditorPanel.tsx` | Complete rewrite: ACE → CodeMirror |
|
|
||||||
| `frontend/src/index.css` | Added CodeMirror flex layout constraints |
|
|
||||||
| `frontend/src/types/vite.d.ts` | **Deleted** — only needed for ACE `?url` imports |
|
|
||||||
| `frontend/vite.config.ts` | Removed `optimizeDeps.include` for ACE |
|
|
||||||
| `pnpm-lock.yaml` | Updated with all new CodeMirror packages |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Remaining Steps / Next Session
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
- [ ] **Test all supported file types** — Verify syntax highlighting works for each language package
|
|
||||||
- [ ] **Test read-only behavior** — Confirm Agent mode prevents editing, User mode allows it
|
|
||||||
- [ ] **Test Ctrl+S save** — Verify keyboard shortcut works in User mode
|
|
||||||
- [ ] **Test file switching** — Open multiple files, ensure language detection works
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
- [ ] **Add SCSS/Sass support** — Currently falls back to CSS mode; could add `@codemirror/lang-sass` if needed
|
|
||||||
- [ ] **Add Ruby support** — No official CM6 package; could use `@codemirror/legacy-modes` if needed
|
|
||||||
- [ ] **Add Dockerfile/Makefile support** — Currently plain text; could add custom language defs if needed
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
- [ ] **Autocompletion** — Currently disabled; could add `@codemirror/autocomplete` + language-specific completions
|
|
||||||
- [ ] **Linting** — Could add `@codemirror/lint` for real-time error annotations
|
|
||||||
- [ ] **Find/Replace** — Could add `@codemirror/search` for Ctrl+F support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Continuation Prompt (if needed)
|
|
||||||
|
|
||||||
> **Context:** User Mode MVP is complete. The CodeMirror editor integration is working correctly with proper flex layout constraints. The editor supports 16 languages, respects read-only mode in Agent mode, allows editing in User mode, and saves with Ctrl+S.
|
|
||||||
>
|
|
||||||
> **Next steps:** Test edge cases (large files, rapid switching, special characters), add missing language support if needed (Ruby, SCSS, Dockerfile), and optionally enhance with autocompletion/linting/search features.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Team Notes
|
|
||||||
|
|
||||||
**What worked well:**
|
|
||||||
- Research-driven approach: We did proper homework on CJS/ESM interop issues before deciding to migrate
|
|
||||||
- Clean migration: The CodeMirror rewrite was done in one clean pass with proper TypeScript types
|
|
||||||
- Proper flex layout fix: Instead of a hack, we added CSS that correctly propagates height constraints through the entire DOM tree
|
|
||||||
|
|
||||||
**What to avoid in future:**
|
|
||||||
- Don't use CJS-only React components in Vite projects — the interop is fragile
|
|
||||||
- Don't add third-party components without testing their flex layout behavior first
|
|
||||||
- Don't skip the research phase — knowing the root cause saved us hours of debugging
|
|
||||||
|
|
||||||
**Shoutouts:**
|
|
||||||
- The heartbeat worker architecture (Web Worker + fallback setInterval) is rock-solid
|
|
||||||
- The ErrorBoundary we added for ACE is still in place and working for CodeMirror
|
|
||||||
- The file loading/saving socket API abstraction made the editor swap trivial — same interface, different implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status:** ✅ User Mode MVP complete. Editor is production-ready.
|
|
||||||
232
pnpm-lock.yaml
232
pnpm-lock.yaml
@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
list: link:../../snap/code/198/.local/share/pnpm/global/5/node_modules/list
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.: {}
|
.: {}
|
||||||
@ -366,6 +369,9 @@ importers:
|
|||||||
'@gadget/ai':
|
'@gadget/ai':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/ai
|
version: link:../packages/ai
|
||||||
|
'@gadget/ai-toolbox':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/ai-toolbox
|
||||||
'@gadget/api':
|
'@gadget/api':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/api
|
version: link:../packages/api
|
||||||
@ -405,6 +411,9 @@ importers:
|
|||||||
simple-git:
|
simple-git:
|
||||||
specifier: ^3.36.0
|
specifier: ^3.36.0
|
||||||
version: 3.36.0
|
version: 3.36.0
|
||||||
|
simplegit:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ^4.8.3
|
specifier: ^4.8.3
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
@ -440,6 +449,37 @@ importers:
|
|||||||
specifier: 4.1.5
|
specifier: 4.1.5
|
||||||
version: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0))
|
version: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0))
|
||||||
|
|
||||||
|
gadget-tasks:
|
||||||
|
dependencies:
|
||||||
|
'@gadget/api':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/api
|
||||||
|
'@gadget/config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/config
|
||||||
|
'@inquirer/prompts':
|
||||||
|
specifier: ^8.4.2
|
||||||
|
version: 8.4.2(@types/node@25.6.0)
|
||||||
|
cron:
|
||||||
|
specifier: ^4.3.1
|
||||||
|
version: 4.4.0
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.10.1
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.3
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.6.0
|
||||||
|
version: 25.6.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.21.0
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: 6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
|
||||||
packages/ai:
|
packages/ai:
|
||||||
dependencies:
|
dependencies:
|
||||||
googleapis:
|
googleapis:
|
||||||
@ -468,6 +508,43 @@ importers:
|
|||||||
specifier: ^4.1.5
|
specifier: ^4.1.5
|
||||||
version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0))
|
version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0))
|
||||||
|
|
||||||
|
packages/ai-toolbox:
|
||||||
|
dependencies:
|
||||||
|
'@gadget/ai':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../ai
|
||||||
|
'@gadget/api':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../api
|
||||||
|
'@mozilla/readability':
|
||||||
|
specifier: 0.6.0
|
||||||
|
version: 0.6.0
|
||||||
|
googleapis:
|
||||||
|
specifier: 171.4.0
|
||||||
|
version: 171.4.0
|
||||||
|
jsdom:
|
||||||
|
specifier: 29.0.2
|
||||||
|
version: 29.0.2
|
||||||
|
playwright:
|
||||||
|
specifier: 1.59.1
|
||||||
|
version: 1.59.1
|
||||||
|
turndown:
|
||||||
|
specifier: 7.2.2
|
||||||
|
version: 7.2.2
|
||||||
|
devDependencies:
|
||||||
|
'@types/jsdom':
|
||||||
|
specifier: 27.0.0
|
||||||
|
version: 27.0.0
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.6.0
|
||||||
|
version: 25.6.0
|
||||||
|
'@types/turndown':
|
||||||
|
specifier: 5.0.5
|
||||||
|
version: 5.0.5
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
|
||||||
packages/api:
|
packages/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansicolor:
|
ansicolor:
|
||||||
@ -1830,6 +1907,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
ansi-regex@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ansi-regex@5.0.1:
|
ansi-regex@5.0.1:
|
||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1880,6 +1961,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-p4jj6Fws4Iy2m0iCmI2am2ZNZCgbdgE+P8F/8csmn2vx7ixXrO2zGcuNsD46X5uZSVecmkEy/M06X2vG8KD6dQ==}
|
resolution: {integrity: sha512-p4jj6Fws4Iy2m0iCmI2am2ZNZCgbdgE+P8F/8csmn2vx7ixXrO2zGcuNsD46X5uZSVecmkEy/M06X2vG8KD6dQ==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
|
|
||||||
|
async@1.5.2:
|
||||||
|
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
||||||
|
|
||||||
async@2.6.4:
|
async@2.6.4:
|
||||||
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
|
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
|
||||||
|
|
||||||
@ -2006,6 +2090,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
camelcase@2.1.1:
|
||||||
|
resolution: {integrity: sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
camera-controls@3.1.2:
|
camera-controls@3.1.2:
|
||||||
resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==}
|
resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==}
|
||||||
engines: {node: '>=22.0.0', npm: '>=10.5.1'}
|
engines: {node: '>=22.0.0', npm: '>=10.5.1'}
|
||||||
@ -2041,6 +2129,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
cliui@3.2.0:
|
||||||
|
resolution: {integrity: sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -2049,6 +2140,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
code-point-at@1.1.0:
|
||||||
|
resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||||
|
|
||||||
@ -2205,6 +2300,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@ -2661,6 +2760,13 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ini@1.3.8:
|
||||||
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
|
invert-kv@1.0.0:
|
||||||
|
resolution: {integrity: sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ioredis@5.10.1:
|
ioredis@5.10.1:
|
||||||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
@ -2692,6 +2798,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@1.0.0:
|
||||||
|
resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0:
|
is-fullwidth-code-point@3.0.0:
|
||||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2801,6 +2911,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==}
|
resolution: {integrity: sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==}
|
||||||
engines: {node: '>=0.2.0'}
|
engines: {node: '>=0.2.0'}
|
||||||
|
|
||||||
|
lcid@1.0.0:
|
||||||
|
resolution: {integrity: sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
less@4.6.4:
|
less@4.6.4:
|
||||||
resolution: {integrity: sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==}
|
resolution: {integrity: sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -3111,6 +3225,10 @@ packages:
|
|||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nconf@0.8.5:
|
||||||
|
resolution: {integrity: sha512-YXTpOk2LI4QD2vAr4v40+nELcEbUA5BkIoeIz4Orfx9XA0Gxkkf70+KOvIVNDP2YQBz5XWg7l1zgiPvWb0zTPw==}
|
||||||
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
needle@3.5.0:
|
needle@3.5.0:
|
||||||
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
|
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
|
||||||
engines: {node: '>= 4.4.x'}
|
engines: {node: '>= 4.4.x'}
|
||||||
@ -3152,6 +3270,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
number-is-nan@1.0.1:
|
||||||
|
resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
numeral@2.0.6:
|
numeral@2.0.6:
|
||||||
resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==}
|
resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==}
|
||||||
|
|
||||||
@ -3200,6 +3322,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==}
|
resolution: {integrity: sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
os-locale@1.4.0:
|
||||||
|
resolution: {integrity: sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
parse-node-version@1.0.1:
|
parse-node-version@1.0.1:
|
||||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@ -3515,6 +3641,9 @@ packages:
|
|||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
secure-keys@1.0.0:
|
||||||
|
resolution: {integrity: sha512-nZi59hW3Sl5P3+wOO89eHBAAGwmCPd2aE1+dLZV5MO+ItQctIvAqihzaAXIQhvtH4KJPxM080HsnqltR2y8cWg==}
|
||||||
|
|
||||||
semver@5.7.2:
|
semver@5.7.2:
|
||||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -3597,6 +3726,10 @@ packages:
|
|||||||
simple-git@3.36.0:
|
simple-git@3.36.0:
|
||||||
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
|
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
|
||||||
|
|
||||||
|
simplegit@1.0.2:
|
||||||
|
resolution: {integrity: sha512-jEQKokh61NP+oqj7oFpXtd29zOSO8rR1X1Rs11tNtBLxLhrR976zBnRmzR6zZes7MkqhVUtS1QnyiuBxGDLDrg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
slash@3.0.0:
|
slash@3.0.0:
|
||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -3691,6 +3824,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
|
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
string-width@1.0.2:
|
||||||
|
resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -3698,6 +3835,10 @@ packages:
|
|||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
|
strip-ansi@3.0.1:
|
||||||
|
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -4060,10 +4201,19 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
window-size@0.1.4:
|
||||||
|
resolution: {integrity: sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw==}
|
||||||
|
engines: {node: '>= 0.10.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
with@7.0.2:
|
with@7.0.2:
|
||||||
resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==}
|
resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
wrap-ansi@2.1.0:
|
||||||
|
resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -4102,6 +4252,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
y18n@3.2.2:
|
||||||
|
resolution: {integrity: sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -4114,6 +4267,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@3.32.0:
|
||||||
|
resolution: {integrity: sha512-ONJZiimStfZzhKamYvR/xvmgW3uEkAUFSP91y2caTEPhzF6uP2JfPiVZcq66b/YR0C3uitxSV7+T1x8p5bkmMg==}
|
||||||
|
|
||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||||
|
|
||||||
@ -5401,6 +5557,8 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
ansi-regex@2.1.1: {}
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
@ -5436,6 +5594,8 @@ snapshots:
|
|||||||
|
|
||||||
async-each-series@0.1.1: {}
|
async-each-series@0.1.1: {}
|
||||||
|
|
||||||
|
async@1.5.2: {}
|
||||||
|
|
||||||
async@2.6.4:
|
async@2.6.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
@ -5615,6 +5775,8 @@ snapshots:
|
|||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
camelcase@2.1.1: {}
|
||||||
|
|
||||||
camera-controls@3.1.2(three@0.184.0):
|
camera-controls@3.1.2(three@0.184.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
three: 0.184.0
|
three: 0.184.0
|
||||||
@ -5652,6 +5814,12 @@ snapshots:
|
|||||||
|
|
||||||
cli-width@4.1.0: {}
|
cli-width@4.1.0: {}
|
||||||
|
|
||||||
|
cliui@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 1.0.2
|
||||||
|
strip-ansi: 3.0.1
|
||||||
|
wrap-ansi: 2.1.0
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@ -5660,6 +5828,8 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
|
code-point-at@1.1.0: {}
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.2
|
'@codemirror/autocomplete': 6.20.2
|
||||||
@ -5808,6 +5978,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decode-uri-component@0.2.2: {}
|
decode-uri-component@0.2.2: {}
|
||||||
@ -6368,6 +6540,10 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ini@1.3.8: {}
|
||||||
|
|
||||||
|
invert-kv@1.0.0: {}
|
||||||
|
|
||||||
ioredis@5.10.1:
|
ioredis@5.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ioredis/commands': 1.5.1
|
'@ioredis/commands': 1.5.1
|
||||||
@ -6407,6 +6583,10 @@ snapshots:
|
|||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
number-is-nan: 1.0.1
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
is-glob@4.0.3:
|
is-glob@4.0.3:
|
||||||
@ -6552,6 +6732,10 @@ snapshots:
|
|||||||
|
|
||||||
lazy@1.0.11: {}
|
lazy@1.0.11: {}
|
||||||
|
|
||||||
|
lcid@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
invert-kv: 1.0.0
|
||||||
|
|
||||||
less@4.6.4:
|
less@4.6.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything: 3.0.5
|
copy-anything: 3.0.5
|
||||||
@ -6822,6 +7006,13 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@5.1.11: {}
|
nanoid@5.1.11: {}
|
||||||
|
|
||||||
|
nconf@0.8.5:
|
||||||
|
dependencies:
|
||||||
|
async: 1.5.2
|
||||||
|
ini: 1.3.8
|
||||||
|
secure-keys: 1.0.0
|
||||||
|
yargs: 3.32.0
|
||||||
|
|
||||||
needle@3.5.0:
|
needle@3.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
@ -6853,6 +7044,8 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
|
number-is-nan@1.0.1: {}
|
||||||
|
|
||||||
numeral@2.0.6: {}
|
numeral@2.0.6: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
@ -6887,6 +7080,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-wsl: 1.1.0
|
is-wsl: 1.1.0
|
||||||
|
|
||||||
|
os-locale@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
lcid: 1.0.0
|
||||||
|
|
||||||
parse-node-version@1.0.1: {}
|
parse-node-version@1.0.1: {}
|
||||||
|
|
||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
@ -7216,6 +7413,8 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
secure-keys@1.0.0: {}
|
||||||
|
|
||||||
semver@5.7.2:
|
semver@5.7.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -7353,6 +7552,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
simplegit@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
nconf: 0.8.5
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
slug@11.0.1: {}
|
slug@11.0.1: {}
|
||||||
@ -7449,6 +7652,12 @@ snapshots:
|
|||||||
|
|
||||||
strict-uri-encode@2.0.0: {}
|
strict-uri-encode@2.0.0: {}
|
||||||
|
|
||||||
|
string-width@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
code-point-at: 1.1.0
|
||||||
|
is-fullwidth-code-point: 1.0.0
|
||||||
|
strip-ansi: 3.0.1
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@ -7459,6 +7668,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
strip-ansi@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
ansi-regex: 2.1.1
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
@ -7810,6 +8023,8 @@ snapshots:
|
|||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
window-size@0.1.4: {}
|
||||||
|
|
||||||
with@7.0.2:
|
with@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
@ -7817,6 +8032,11 @@ snapshots:
|
|||||||
assert-never: 1.4.0
|
assert-never: 1.4.0
|
||||||
babel-walk: 3.0.0-canary-5
|
babel-walk: 3.0.0-canary-5
|
||||||
|
|
||||||
|
wrap-ansi@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 1.0.2
|
||||||
|
strip-ansi: 3.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@ -7840,6 +8060,8 @@ snapshots:
|
|||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
|
y18n@3.2.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
@ -7854,6 +8076,16 @@ snapshots:
|
|||||||
y18n: 5.0.8
|
y18n: 5.0.8
|
||||||
yargs-parser: 21.1.1
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
|
yargs@3.32.0:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 2.1.1
|
||||||
|
cliui: 3.2.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
os-locale: 1.4.0
|
||||||
|
string-width: 1.0.2
|
||||||
|
window-size: 0.1.4
|
||||||
|
y18n: 3.2.2
|
||||||
|
|
||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer-crc32: 0.2.13
|
buffer-crc32: 0.2.13
|
||||||
|
|||||||
@ -3,6 +3,9 @@ packages:
|
|||||||
- 'gadget-code'
|
- 'gadget-code'
|
||||||
- 'gadget-code/frontend'
|
- 'gadget-code/frontend'
|
||||||
- 'gadget-drone'
|
- 'gadget-drone'
|
||||||
|
- 'gadget-tasks'
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msgpackr-extract: true
|
msgpackr-extract: true
|
||||||
|
overrides:
|
||||||
|
list: link:../../snap/code/198/.local/share/pnpm/global/5/node_modules/list
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user