Compare commits
14 commits
PAP-878-cr
...
docs/maint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712ccc802f | ||
|
|
bfe97e08f7 | ||
|
|
e7883135f3 | ||
|
|
37cce49328 | ||
|
|
a34eb3766f | ||
|
|
e4a114331e | ||
|
|
694c2922d0 | ||
|
|
8093fbf09b | ||
|
|
1e805ef1b0 | ||
|
|
3220941a9a | ||
|
|
bd5c988728 | ||
|
|
52dab938cb | ||
|
|
334e7e61b5 | ||
|
|
eda69fed74 |
47 changed files with 1298 additions and 217 deletions
|
|
@ -63,7 +63,7 @@ async function startTempDatabase() {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||||
|
|
||||||
describe("PaperclipApiClient", () => {
|
describe("PaperclipApiClient", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => {
|
||||||
} satisfies Partial<ApiRequestError>);
|
} satisfies Partial<ApiRequestError>);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||||
|
|
||||||
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
|
||||||
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
|
||||||
|
url: "http://localhost:3100/api/companies/import/preview",
|
||||||
|
method: "POST",
|
||||||
|
causeMessage: "fetch failed",
|
||||||
|
} satisfies Partial<ApiConnectionError>);
|
||||||
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||||
|
/Could not reach the Paperclip API\./,
|
||||||
|
);
|
||||||
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||||
|
/curl http:\/\/localhost:3100\/api\/health/,
|
||||||
|
);
|
||||||
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||||
|
/pnpm dev|pnpm paperclipai run/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("retries once after interactive auth recovery", async () => {
|
it("retries once after interactive auth recovery", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,26 @@ export class ApiRequestError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiConnectionError extends Error {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
causeMessage?: string;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
apiBase: string;
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
cause?: unknown;
|
||||||
|
}) {
|
||||||
|
const url = buildUrl(input.apiBase, input.path);
|
||||||
|
const causeMessage = formatConnectionCause(input.cause);
|
||||||
|
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
|
||||||
|
this.url = url;
|
||||||
|
this.method = input.method;
|
||||||
|
this.causeMessage = causeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
ignoreNotFound?: boolean;
|
ignoreNotFound?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +96,7 @@ export class PaperclipApiClient {
|
||||||
hasRetriedAuth = false,
|
hasRetriedAuth = false,
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
const url = buildUrl(this.apiBase, path);
|
const url = buildUrl(this.apiBase, path);
|
||||||
|
const method = String(init.method ?? "GET").toUpperCase();
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
|
|
@ -94,10 +115,20 @@ export class PaperclipApiClient {
|
||||||
headers["x-paperclip-run-id"] = this.runId;
|
headers["x-paperclip-run-id"] = this.runId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Response;
|
||||||
...init,
|
try {
|
||||||
headers,
|
response = await fetch(url, {
|
||||||
});
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiConnectionError({
|
||||||
|
apiBase: this.apiBase,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.ignoreNotFound && response.status === 404) {
|
if (opts?.ignoreNotFound && response.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -108,7 +139,7 @@ export class PaperclipApiClient {
|
||||||
if (!hasRetriedAuth && this.recoverAuth) {
|
if (!hasRetriedAuth && this.recoverAuth) {
|
||||||
const recoveredToken = await this.recoverAuth({
|
const recoveredToken = await this.recoverAuth({
|
||||||
path,
|
path,
|
||||||
method: String(init.method ?? "GET").toUpperCase(),
|
method,
|
||||||
error: apiError,
|
error: apiError,
|
||||||
});
|
});
|
||||||
if (recoveredToken) {
|
if (recoveredToken) {
|
||||||
|
|
@ -166,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
|
||||||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConnectionErrorMessage(input: {
|
||||||
|
apiBase: string;
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
causeMessage?: string;
|
||||||
|
}): string {
|
||||||
|
const healthUrl = buildHealthCheckUrl(input.url);
|
||||||
|
const lines = [
|
||||||
|
"Could not reach the Paperclip API.",
|
||||||
|
"",
|
||||||
|
`Request: ${input.method} ${input.url}`,
|
||||||
|
];
|
||||||
|
if (input.causeMessage) {
|
||||||
|
lines.push(`Cause: ${input.causeMessage}`);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||||
|
"",
|
||||||
|
"Try:",
|
||||||
|
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||||
|
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||||
|
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHealthCheckUrl(requestUrl: string): string {
|
||||||
|
const url = new URL(requestUrl);
|
||||||
|
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConnectionCause(error: unknown): string | undefined {
|
||||||
|
if (!error) return undefined;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message.trim() || error.name;
|
||||||
|
}
|
||||||
|
const message = String(error).trim();
|
||||||
|
return message || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||||
if (!headers) return {};
|
if (!headers) return {};
|
||||||
if (Array.isArray(headers)) {
|
if (Array.isArray(headers)) {
|
||||||
|
|
|
||||||
|
|
@ -756,7 +756,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,9 @@ Public packages are discovered from:
|
||||||
|
|
||||||
- `packages/`
|
- `packages/`
|
||||||
- `server/`
|
- `server/`
|
||||||
|
- `ui/`
|
||||||
- `cli/`
|
- `cli/`
|
||||||
|
|
||||||
`ui/` is ignored because it is private.
|
|
||||||
|
|
||||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||||
|
|
||||||
- finds all public packages
|
- finds all public packages
|
||||||
|
|
@ -65,6 +64,18 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
|
||||||
|
|
||||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||||
|
|
||||||
|
## `@paperclipai/ui` packaging
|
||||||
|
|
||||||
|
The UI package publishes prebuilt static assets, not the source workspace.
|
||||||
|
|
||||||
|
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
|
||||||
|
|
||||||
|
- keeps the release-managed `name` and `version`
|
||||||
|
- publishes only `dist/`
|
||||||
|
- omits the source-only dependency graph from downstream installs
|
||||||
|
|
||||||
|
After packing or publishing, `postpack` restores the development manifest automatically.
|
||||||
|
|
||||||
## Version formats
|
## Version formats
|
||||||
|
|
||||||
Paperclip uses calendar versions:
|
Paperclip uses calendar versions:
|
||||||
|
|
@ -135,6 +146,7 @@ This is the fastest way to restore the default install path if a stable release
|
||||||
|
|
||||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||||
|
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
|
||||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||||
- [`doc/RELEASING.md`](RELEASING.md)
|
- [`doc/RELEASING.md`](RELEASING.md)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ At minimum that includes:
|
||||||
|
|
||||||
- `paperclipai`
|
- `paperclipai`
|
||||||
- `@paperclipai/server`
|
- `@paperclipai/server`
|
||||||
|
- `@paperclipai/ui`
|
||||||
- public packages under `packages/`
|
- public packages under `packages/`
|
||||||
|
|
||||||
### 2.1. In npm, open each package settings page
|
### 2.1. In npm, open each package settings page
|
||||||
|
|
|
||||||
31
doc/SPEC.md
31
doc/SPEC.md
|
|
@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||||
|
|
||||||
### Execution Adapters
|
### Execution Adapters
|
||||||
|
|
||||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
|
||||||
|
|
||||||
| Adapter | Mechanism | Example |
|
| Adapter | Mechanism | Example |
|
||||||
| -------------------- | ----------------------- | --------------------------------------------- |
|
| ---------------- | -------------------------- | -------------------------------------------------- |
|
||||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
|
||||||
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
||||||
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
||||||
|
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
|
||||||
|
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
||||||
|
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||||
|
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
||||||
|
|
||||||
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||||
|
|
||||||
### Adapter Interface
|
### Adapter Interface
|
||||||
|
|
||||||
|
|
@ -376,7 +380,7 @@ Flow:
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
| -------- | ------------------------------------------------------------ |
|
| -------- | ------------------------------------------------------------ |
|
||||||
| Frontend | React + Vite |
|
| Frontend | React + Vite |
|
||||||
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
|
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
|
||||||
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
||||||
| Auth | [Better Auth](https://www.better-auth.com/) |
|
| Auth | [Better Auth](https://www.better-auth.com/) |
|
||||||
|
|
||||||
|
|
@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
||||||
|
|
||||||
### Work Artifacts
|
### Work Artifacts
|
||||||
|
|
||||||
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
|
Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain.
|
||||||
|
|
||||||
### Open Questions
|
### Open Questions
|
||||||
|
|
||||||
|
|
@ -476,15 +480,14 @@ Each is a distinct page/route:
|
||||||
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
||||||
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
||||||
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
||||||
- [ ] **REST API** — full API for agent interaction (Hono)
|
- [ ] **REST API** — full API for agent interaction (Express)
|
||||||
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
||||||
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
||||||
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
||||||
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
|
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
|
||||||
|
|
||||||
### Not V1
|
### Not V1
|
||||||
|
|
||||||
- Template export/import
|
|
||||||
- Knowledge base - a future plugin
|
- Knowledge base - a future plugin
|
||||||
- Advanced governance models (hiring budgets, multi-member boards)
|
- Advanced governance models (hiring budgets, multi-member boards)
|
||||||
- Revenue/expense tracking beyond token costs - a future plugin
|
- Revenue/expense tracking beyond token costs - a future plugin
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@
|
||||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^10.1.0",
|
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
|
|
@ -44,5 +44,10 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4"
|
"packageManager": "pnpm@9.15.4",
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||||
effectiveInstructionsFilePath = combinedPath;
|
effectiveInstructionsFilePath = combinedPath;
|
||||||
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -415,10 +415,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
instructionsChars = instructionsPrefix.length;
|
instructionsChars = instructionsPrefix.length;
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -307,10 +307,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
instructionsChars = instructionsPrefix.length;
|
instructionsChars = instructionsPrefix.length;
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -253,10 +253,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`${instructionsContents}\n\n` +
|
`${instructionsContents}\n\n` +
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`${instructionsContents}\n\n` +
|
`${instructionsContents}\n\n` +
|
||||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -266,10 +266,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
instructionsReadFailed = true;
|
instructionsReadFailed = true;
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ async function createTempDatabase(): Promise<string> {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port: selectedPort,
|
port: selectedPort,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
22
patches/embedded-postgres@18.1.0-beta.16.patch
Normal file
22
patches/embedded-postgres@18.1.0-beta.16.patch
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
|
index ccfe17a82f4879bf20cc345c579a987d9eba5309..dd689f5908f625f49b4785318daea736aa88927f 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -133,7 +133,7 @@ class EmbeddedPostgres {
|
||||||
|
`--pwfile=${passwordFile}`,
|
||||||
|
`--lc-messages=${LC_MESSAGES_LOCALE}`,
|
||||||
|
...this.options.initdbFlags,
|
||||||
|
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
||||||
|
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
||||||
|
// Connect to stderr, as that is where the messages get sent
|
||||||
|
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
||||||
|
// Parse the data as a string and log it
|
||||||
|
@@ -177,7 +177,7 @@ class EmbeddedPostgres {
|
||||||
|
'-p',
|
||||||
|
this.options.port.toString(),
|
||||||
|
...this.options.postgresFlags,
|
||||||
|
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
||||||
|
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
||||||
|
// Connect to stderr, as that is where the messages get sent
|
||||||
|
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
||||||
|
// Parse the data as a string and log it
|
||||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
|
|
@ -4,6 +4,11 @@ settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
embedded-postgres@18.1.0-beta.16:
|
||||||
|
hash: qmixl47dgryk2bbwt4egonhgem
|
||||||
|
path: patches/embedded-postgres@18.1.0-beta.16.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
|
@ -22,7 +27,7 @@ importers:
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
|
||||||
cli:
|
cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -73,7 +78,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16
|
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
|
|
@ -225,7 +230,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16
|
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.5
|
specifier: ^3.4.5
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
|
|
@ -244,7 +249,7 @@ importers:
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
|
||||||
packages/plugins/create-paperclip-plugin:
|
packages/plugins/create-paperclip-plugin:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -294,7 +299,7 @@ importers:
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
|
||||||
packages/plugins/examples/plugin-file-browser-example:
|
packages/plugins/examples/plugin-file-browser-example:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -494,7 +499,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16
|
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
|
@ -570,7 +575,7 @@ importers:
|
||||||
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -691,7 +696,7 @@ importers:
|
||||||
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
|
@ -9973,7 +9978,7 @@ snapshots:
|
||||||
|
|
||||||
electron-to-chromium@1.5.286: {}
|
electron-to-chromium@1.5.286: {}
|
||||||
|
|
||||||
embedded-postgres@18.1.0-beta.16:
|
embedded-postgres@18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem):
|
||||||
dependencies:
|
dependencies:
|
||||||
async-exit-hook: 2.0.1
|
async-exit-hook: 2.0.1
|
||||||
pg: 8.18.0
|
pg: 8.18.0
|
||||||
|
|
@ -12169,8 +12174,52 @@ snapshots:
|
||||||
- terser
|
- terser
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
optional: true
|
||||||
|
|
||||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0):
|
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/expect': 3.2.4
|
||||||
|
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
'@vitest/runner': 3.2.4
|
||||||
|
'@vitest/snapshot': 3.2.4
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.3
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/debug': 4.1.12
|
||||||
|
'@types/node': 24.12.0
|
||||||
|
jsdom: 28.1.0(@noble/hashes@2.0.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
|
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
|
|
|
||||||
77
releases/v2026.325.0.md
Normal file
77
releases/v2026.325.0.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# v2026.325.0
|
||||||
|
|
||||||
|
> Released: 2026-03-25
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Company import/export** — Full company portability with a file-browser UX for importing and exporting agent companies. Includes rich frontmatter preview, nested file picker, merge-history support, GitHub shorthand refs, and CLI `company import`/`company export` commands. Imported companies open automatically after import, and heartbeat timers are disabled for imported agents by default. ([#840](https://github.com/paperclipai/paperclip/pull/840), [#1631](https://github.com/paperclipai/paperclip/pull/1631), [#1632](https://github.com/paperclipai/paperclip/pull/1632), [#1655](https://github.com/paperclipai/paperclip/pull/1655))
|
||||||
|
- **Company skills library** — New company-scoped skills system with a skills UI, agent skill sync across all local adapters (Claude, Codex, Pi, Gemini), pinned GitHub skills with update checks, and built-in skill support. ([#1346](https://github.com/paperclipai/paperclip/pull/1346))
|
||||||
|
- **Routines and recurring tasks** — Full routines engine with triggers, routine runs, coalescing, and recurring task portability. Includes API documentation and routine export support. ([#1351](https://github.com/paperclipai/paperclip/pull/1351), [#1622](https://github.com/paperclipai/paperclip/pull/1622), @aronprins)
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Inline join requests in inbox** — Join requests now render inline in the inbox alongside approvals and other work items.
|
||||||
|
- **Onboarding seeding** — New projects and issues are seeded with goal context during onboarding for a better first-run experience.
|
||||||
|
- **Agent instructions recovery** — Managed agent instructions are recovered from disk on startup; instructions are preserved across adapter switches.
|
||||||
|
- **Heartbeats settings page** — Shows all agents regardless of interval config; added a "Disable All" button for quick bulk control.
|
||||||
|
- **Agent history via participation** — Agent issue history now uses participation records instead of direct assignment lookups.
|
||||||
|
- **Alphabetical agent sorting** — Agents are sorted alphabetically by name across all views.
|
||||||
|
- **Company org chart assets** — Improved generated org chart visuals for companies.
|
||||||
|
- **Improved CLI API connection errors** — Better error messages when the CLI cannot reach the Paperclip API.
|
||||||
|
- **Markdown mention links** — Custom URL schemes are now allowed in Lexical LinkNode, enabling mention pills with proper linking behavior. Atomic deletion of mention pills works correctly.
|
||||||
|
- **Issue workspace reuse** — Workspaces are correctly reused after isolation runs.
|
||||||
|
- **Failed-run session resume** — Explicit failed-run sessions can now be resumed via honor flag.
|
||||||
|
- **Docker image CI** — Added Docker image build and deploy workflow. ([#542](https://github.com/paperclipai/paperclip/pull/542), @albttx)
|
||||||
|
- **Project filter on issues** — Issues list can now be filtered by project. ([#552](https://github.com/paperclipai/paperclip/pull/552), @mvanhorn)
|
||||||
|
- **Inline comment image attachments** — Uploaded images are now embedded inline in comments. ([#551](https://github.com/paperclipai/paperclip/pull/551), @mvanhorn)
|
||||||
|
- **AGENTS.md fallback** — Claude-local adapter gracefully falls back when AGENTS.md is missing. ([#550](https://github.com/paperclipai/paperclip/pull/550), @mvanhorn)
|
||||||
|
- **Company-creator skill** — New skill for scaffolding agent company packages from scratch.
|
||||||
|
- **Reports page rename** — Reports section renamed for clarity. ([#1380](https://github.com/paperclipai/paperclip/pull/1380), @DanielSousa)
|
||||||
|
- **Eval framework bootstrap** — Promptfoo-based evaluation framework with YAML test cases for systematic agent behavior testing. ([#832](https://github.com/paperclipai/paperclip/pull/832), @mvanhorn)
|
||||||
|
- **Board CLI authentication** — Browser-based auth flow for the CLI so board users can authenticate without manually copying API keys. ([#1635](https://github.com/paperclipai/paperclip/pull/1635))
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- **Embedded Postgres initdb in Docker slim** — Fixed initdb failure in slim containers by adding proper initdbFlags types. ([#737](https://github.com/paperclipai/paperclip/pull/737), @alaa-alghazouli)
|
||||||
|
- **OpenClaw gateway crash** — Fixed unhandled rejection when challengePromise fails. ([#743](https://github.com/paperclipai/paperclip/pull/743), @Sigmabrogz)
|
||||||
|
- **Agent mention pill alignment** — Fixed vertical misalignment between agent mention pills and project mention pills.
|
||||||
|
- **Task assignment grants** — Preserved task assignment grants for agents that have already joined.
|
||||||
|
- **Instructions tab state** — Fixed tab state not updating correctly when switching between agents.
|
||||||
|
- **Imported agent bundle frontmatter** — Fixed frontmatter leakage in imported agent bundles.
|
||||||
|
- **Login form 1Password detection** — Fixed login form not being detected by password managers; Enter key now submits correctly. ([#1014](https://github.com/paperclipai/paperclip/pull/1014))
|
||||||
|
- **Pill contrast (WCAG)** — Improved mention pill contrast using WCAG contrast ratios on composited backgrounds.
|
||||||
|
- **Documents horizontal scroll** — Prevented documents row from causing horizontal scroll on mobile.
|
||||||
|
- **Toggle switch sizing** — Fixed oversized toggle switches on mobile; added missing `data-slot` attributes.
|
||||||
|
- **Agent instructions tab responsive** — Made agent instructions tab responsive on mobile.
|
||||||
|
- **Monospace font sizing** — Adjusted inline code font size and added dark mode background.
|
||||||
|
- **Priority icon removal** — Removed priority icon from issue rows for a cleaner list view.
|
||||||
|
- **Same-page issue toasts** — Suppressed redundant toasts when navigating to an issue already on screen.
|
||||||
|
- **Noisy adapter log** — Removed noisy "Loaded agent instructions file" log message from all adapters.
|
||||||
|
- **Pi local adapter** — Fixed Pi adapter missing from `isLocal` check. ([#1382](https://github.com/paperclipai/paperclip/pull/1382), @lucas-stellet)
|
||||||
|
- **CLI auth migration idempotency** — Made migration 0044 idempotent to avoid failures on re-run.
|
||||||
|
- **Dev restart tracking** — `.paperclip` and test-only paths are now ignored in dev restart detection.
|
||||||
|
- **Duplicate CLI auth flag** — Fixed duplicate `--company` flag on `auth login`.
|
||||||
|
- **Gemini local execution** — Fixed Gemini local adapter execution and diagnostics.
|
||||||
|
- **Sidebar ordering** — Preserved sidebar ordering during company portability operations.
|
||||||
|
- **Company skill deduplication** — Fixed duplicate skill inventory refreshes.
|
||||||
|
- **Worktree merge-history migrations** — Fixed migration handling in worktree contexts. ([#1385](https://github.com/paperclipai/paperclip/pull/1385))
|
||||||
|
|
||||||
|
## Upgrade Guide
|
||||||
|
|
||||||
|
Seven new database migrations (`0038`–`0044`) will run automatically on startup:
|
||||||
|
|
||||||
|
- **Migration 0038** adds process tracking columns to heartbeat runs (PID, started-at, retry tracking).
|
||||||
|
- **Migration 0039** adds the routines engine tables (routines, triggers, routine runs).
|
||||||
|
- **Migrations 0040–0042** extend company skills, recurring tasks, and portability metadata.
|
||||||
|
- **Migration 0043** adds the Codex managed-home and agent instructions recovery columns.
|
||||||
|
- **Migration 0044** adds board API keys and CLI auth challenge tables for browser-based CLI auth.
|
||||||
|
|
||||||
|
All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
|
||||||
|
|
||||||
|
If you use the company import/export feature, note that imported companies have heartbeat timers disabled by default. Re-enable them manually from the Heartbeats settings page after verifying adapter configuration.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who contributed to this release!
|
||||||
|
|
||||||
|
@alaa-alghazouli, @albttx, @AOrobator, @aronprins, @cryppadotta, @DanielSousa, @lucas-stellet, @mvanhorn, @richardanaya, @Sigmabrogz
|
||||||
31
scripts/generate-ui-package-json.mjs
Normal file
31
scripts/generate-ui-package-json.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = resolve(__dirname, "..");
|
||||||
|
const uiDir = join(repoRoot, "ui");
|
||||||
|
const packageJsonPath = join(uiDir, "package.json");
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||||
|
|
||||||
|
const publishPackageJson = {
|
||||||
|
name: packageJson.name,
|
||||||
|
version: packageJson.version,
|
||||||
|
description: packageJson.description,
|
||||||
|
license: packageJson.license,
|
||||||
|
homepage: packageJson.homepage,
|
||||||
|
bugs: packageJson.bugs,
|
||||||
|
repository: packageJson.repository,
|
||||||
|
type: packageJson.type,
|
||||||
|
files: ["dist"],
|
||||||
|
publishConfig: {
|
||||||
|
access: "public",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(" ✓ Generated publishable UI package.json");
|
||||||
|
|
@ -2108,5 +2108,8 @@ describe("company portability", () => {
|
||||||
replaceExisting: true,
|
replaceExisting: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const materializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls[0]?.[1] as Record<string, string>;
|
||||||
|
expect(materializedFiles["AGENTS.md"]).not.toMatch(/^---\n/);
|
||||||
|
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ async function startTempDatabase() {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
||||||
|
|
||||||
|
describe("agentJoinGrantsFromDefaults", () => {
|
||||||
|
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
||||||
|
expect(agentJoinGrantsFromDefaults(null)).toEqual([
|
||||||
|
{
|
||||||
|
permissionKey: "tasks:assign",
|
||||||
|
scope: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves invite agent grants and appends tasks:assign", () => {
|
||||||
|
expect(
|
||||||
|
agentJoinGrantsFromDefaults({
|
||||||
|
agent: {
|
||||||
|
grants: [
|
||||||
|
{
|
||||||
|
permissionKey: "agents:create",
|
||||||
|
scope: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
permissionKey: "agents:create",
|
||||||
|
scope: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permissionKey: "tasks:assign",
|
||||||
|
scope: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate tasks:assign when invite defaults already include it", () => {
|
||||||
|
expect(
|
||||||
|
agentJoinGrantsFromDefaults({
|
||||||
|
agent: {
|
||||||
|
grants: [
|
||||||
|
{
|
||||||
|
permissionKey: "tasks:assign",
|
||||||
|
scope: { projectId: "project-1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
permissionKey: "tasks:assign",
|
||||||
|
scope: { projectId: "project-1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,16 +20,29 @@ describe("issue goal fallback", () => {
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: null,
|
projectId: null,
|
||||||
goalId: "goal-2",
|
goalId: "goal-2",
|
||||||
|
projectGoalId: "goal-3",
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-2");
|
).toBe("goal-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not force a company goal when the issue belongs to a project", () => {
|
it("inherits the project goal when creating a project-linked issue", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
|
projectGoalId: "goal-2",
|
||||||
|
defaultGoalId: "goal-1",
|
||||||
|
}),
|
||||||
|
).toBe("goal-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not force a company goal when the project has no goal", () => {
|
||||||
|
expect(
|
||||||
|
resolveIssueGoalId({
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
projectGoalId: null,
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
@ -40,20 +53,47 @@ describe("issue goal fallback", () => {
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: null,
|
currentGoalId: null,
|
||||||
|
currentProjectGoalId: null,
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-1");
|
).toBe("goal-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears the fallback when a project is added later", () => {
|
it("switches from the company fallback to the project goal when a project is added later", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: "goal-1",
|
currentGoalId: "goal-1",
|
||||||
|
currentProjectGoalId: null,
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
|
projectGoalId: "goal-2",
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBe("goal-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills the project goal for legacy project-linked issues on update", () => {
|
||||||
|
expect(
|
||||||
|
resolveNextIssueGoalId({
|
||||||
|
currentProjectId: "project-1",
|
||||||
|
currentGoalId: null,
|
||||||
|
currentProjectGoalId: "goal-2",
|
||||||
|
defaultGoalId: "goal-1",
|
||||||
|
}),
|
||||||
|
).toBe("goal-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an explicit goal across project fallback changes", () => {
|
||||||
|
expect(
|
||||||
|
resolveNextIssueGoalId({
|
||||||
|
currentProjectId: "project-1",
|
||||||
|
currentGoalId: "goal-explicit",
|
||||||
|
currentProjectGoalId: "goal-2",
|
||||||
|
projectId: "project-2",
|
||||||
|
projectGoalId: "goal-3",
|
||||||
|
defaultGoalId: "goal-1",
|
||||||
|
}),
|
||||||
|
).toBe("goal-explicit");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
getAncestors: vi.fn(),
|
||||||
|
findMentionedProjectIds: vi.fn(),
|
||||||
|
getCommentCursor: vi.fn(),
|
||||||
|
getComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProjectService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
listByIds: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGoalService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
getDefaultCompanyGoal: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", () => ({
|
||||||
|
accessService: () => ({
|
||||||
|
canUser: vi.fn(),
|
||||||
|
hasPermission: vi.fn(),
|
||||||
|
}),
|
||||||
|
agentService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}),
|
||||||
|
documentService: () => ({
|
||||||
|
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||||
|
}),
|
||||||
|
executionWorkspaceService: () => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}),
|
||||||
|
goalService: () => mockGoalService,
|
||||||
|
heartbeatService: () => ({
|
||||||
|
wakeup: vi.fn(async () => undefined),
|
||||||
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
issueApprovalService: () => ({}),
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
logActivity: vi.fn(async () => undefined),
|
||||||
|
projectService: () => mockProjectService,
|
||||||
|
routineService: () => ({
|
||||||
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
workProductService: () => ({
|
||||||
|
listForIssue: vi.fn(async () => []),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", issueRoutes({} as any, {} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyProjectLinkedIssue = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-581",
|
||||||
|
title: "Legacy onboarding task",
|
||||||
|
description: "Seed the first CEO task",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
projectId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
assigneeUserId: null,
|
||||||
|
updatedAt: new Date("2026-03-24T12:00:00Z"),
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectGoal = {
|
||||||
|
id: "44444444-4444-4444-8444-444444444444",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Launch the company",
|
||||||
|
description: null,
|
||||||
|
level: "company",
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("issue goal context routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||||
|
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||||
|
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||||
|
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||||
|
totalComments: 0,
|
||||||
|
latestCommentId: null,
|
||||||
|
latestCommentAt: null,
|
||||||
|
});
|
||||||
|
mockIssueService.getComment.mockResolvedValue(null);
|
||||||
|
mockProjectService.getById.mockResolvedValue({
|
||||||
|
id: legacyProjectLinkedIssue.projectId,
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "onboarding",
|
||||||
|
goalId: projectGoal.id,
|
||||||
|
goalIds: [projectGoal.id],
|
||||||
|
goals: [{ id: projectGoal.id, title: projectGoal.title }],
|
||||||
|
name: "Onboarding",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/company-1/project-1",
|
||||||
|
effectiveLocalFolder: "/tmp/company-1/project-1",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||||
|
});
|
||||||
|
mockProjectService.listByIds.mockResolvedValue([]);
|
||||||
|
mockGoalService.getById.mockImplementation(async (id: string) =>
|
||||||
|
id === projectGoal.id ? projectGoal : null,
|
||||||
|
);
|
||||||
|
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||||
|
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.goalId).toBe(projectGoal.id);
|
||||||
|
expect(res.body.goal).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: projectGoal.id,
|
||||||
|
title: projectGoal.title,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
||||||
|
const res = await request(createApp()).get(
|
||||||
|
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||||
|
expect(res.body.goal).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: projectGoal.id,
|
||||||
|
title: projectGoal.title,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -68,7 +68,7 @@ async function startTempDatabase() {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ async function startTempDatabase() {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async function startTempDatabase() {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: () => {},
|
onLog: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||||
onLog: appendEmbeddedPostgresLog,
|
onLog: appendEmbeddedPostgresLog,
|
||||||
onError: appendEmbeddedPostgresLog,
|
onError: appendEmbeddedPostgresLog,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1411,6 +1411,25 @@ function grantsFromDefaults(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentJoinGrantsFromDefaults(
|
||||||
|
defaultsPayload: Record<string, unknown> | null | undefined
|
||||||
|
): Array<{
|
||||||
|
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||||
|
scope: Record<string, unknown> | null;
|
||||||
|
}> {
|
||||||
|
const grants = grantsFromDefaults(defaultsPayload, "agent");
|
||||||
|
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
|
||||||
|
return grants;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...grants,
|
||||||
|
{
|
||||||
|
permissionKey: "tasks:assign",
|
||||||
|
scope: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
type JoinRequestManagerCandidate = {
|
type JoinRequestManagerCandidate = {
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
@ -2618,17 +2637,8 @@ export function accessRoutes(
|
||||||
"member",
|
"member",
|
||||||
"active"
|
"active"
|
||||||
);
|
);
|
||||||
await access.setPrincipalPermission(
|
const grants = agentJoinGrantsFromDefaults(
|
||||||
companyId,
|
invite.defaultsPayload as Record<string, unknown> | null
|
||||||
"agent",
|
|
||||||
created.id,
|
|
||||||
"tasks:assign",
|
|
||||||
true,
|
|
||||||
req.actor.userId ?? null
|
|
||||||
);
|
|
||||||
const grants = grantsFromDefaults(
|
|
||||||
invite.defaultsPayload as Record<string, unknown> | null,
|
|
||||||
"agent"
|
|
||||||
);
|
);
|
||||||
await access.setPrincipalGrants(
|
await access.setPrincipalGrants(
|
||||||
companyId,
|
companyId,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,33 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
return rawId;
|
return rawId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveIssueProjectAndGoal(issue: {
|
||||||
|
companyId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
goalId: string | null;
|
||||||
|
}) {
|
||||||
|
const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null);
|
||||||
|
const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null);
|
||||||
|
const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]);
|
||||||
|
|
||||||
|
if (directGoal) {
|
||||||
|
return { project, goal: directGoal };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null;
|
||||||
|
if (projectGoalId) {
|
||||||
|
const projectGoal = await goalsSvc.getById(projectGoalId);
|
||||||
|
return { project, goal: projectGoal };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issue.projectId) {
|
||||||
|
const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId);
|
||||||
|
return { project, goal: defaultGoal };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { project, goal: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
||||||
router.param("id", async (req, res, next, rawId) => {
|
router.param("id", async (req, res, next, rawId) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -311,14 +338,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
|
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||||
|
resolveIssueProjectAndGoal(issue),
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
|
||||||
issue.goalId
|
|
||||||
? goalsSvc.getById(issue.goalId)
|
|
||||||
: !issue.projectId
|
|
||||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
|
||||||
: null,
|
|
||||||
svc.findMentionedProjectIds(issue.id),
|
svc.findMentionedProjectIds(issue.id),
|
||||||
documentsSvc.getIssueDocumentPayload(issue),
|
documentsSvc.getIssueDocumentPayload(issue),
|
||||||
]);
|
]);
|
||||||
|
|
@ -356,14 +378,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
? req.query.wakeCommentId.trim()
|
? req.query.wakeCommentId.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
|
const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([
|
||||||
|
resolveIssueProjectAndGoal(issue),
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
|
||||||
issue.goalId
|
|
||||||
? goalsSvc.getById(issue.goalId)
|
|
||||||
: !issue.projectId
|
|
||||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
|
||||||
: null,
|
|
||||||
svc.getCommentCursor(issue.id),
|
svc.getCommentCursor(issue.id),
|
||||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -3864,6 +3864,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
: []),
|
: []),
|
||||||
);
|
);
|
||||||
const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path);
|
const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path);
|
||||||
|
const entryRelativePath = normalizePortablePath(manifestAgent.path).startsWith(bundlePrefix)
|
||||||
|
? normalizePortablePath(manifestAgent.path).slice(bundlePrefix.length)
|
||||||
|
: "AGENTS.md";
|
||||||
|
if (typeof markdownRaw === "string") {
|
||||||
|
const importedInstructionsBody = parseFrontmatterMarkdown(markdownRaw).body;
|
||||||
|
bundleFiles[entryRelativePath] = importedInstructionsBody;
|
||||||
|
if (entryRelativePath !== "AGENTS.md" && !bundleFiles["AGENTS.md"]) {
|
||||||
|
bundleFiles["AGENTS.md"] = importedInstructionsBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
||||||
if (!markdownRaw && fallbackPromptTemplate) {
|
if (!markdownRaw && fallbackPromptTemplate) {
|
||||||
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,54 @@ type MaybeId = string | null | undefined;
|
||||||
export function resolveIssueGoalId(input: {
|
export function resolveIssueGoalId(input: {
|
||||||
projectId: MaybeId;
|
projectId: MaybeId;
|
||||||
goalId: MaybeId;
|
goalId: MaybeId;
|
||||||
|
projectGoalId?: MaybeId;
|
||||||
defaultGoalId: MaybeId;
|
defaultGoalId: MaybeId;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!input.projectId && !input.goalId) {
|
if (input.goalId) return input.goalId;
|
||||||
return input.defaultGoalId ?? null;
|
if (input.projectId) return input.projectGoalId ?? null;
|
||||||
}
|
return input.defaultGoalId ?? null;
|
||||||
return input.goalId ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNextIssueGoalId(input: {
|
export function resolveNextIssueGoalId(input: {
|
||||||
currentProjectId: MaybeId;
|
currentProjectId: MaybeId;
|
||||||
currentGoalId: MaybeId;
|
currentGoalId: MaybeId;
|
||||||
|
currentProjectGoalId?: MaybeId;
|
||||||
projectId?: MaybeId;
|
projectId?: MaybeId;
|
||||||
goalId?: MaybeId;
|
goalId?: MaybeId;
|
||||||
|
projectGoalId?: MaybeId;
|
||||||
defaultGoalId: MaybeId;
|
defaultGoalId: MaybeId;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const projectId =
|
const projectId =
|
||||||
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
||||||
const goalId =
|
const projectGoalId =
|
||||||
input.goalId !== undefined ? input.goalId : input.currentGoalId;
|
input.projectGoalId !== undefined
|
||||||
|
? input.projectGoalId
|
||||||
|
: projectId
|
||||||
|
? input.currentProjectGoalId
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!projectId && !goalId) {
|
const resolveFallbackGoalId = (targetProjectId: MaybeId, targetProjectGoalId: MaybeId) => {
|
||||||
|
if (targetProjectId) return targetProjectGoalId ?? null;
|
||||||
return input.defaultGoalId ?? null;
|
return input.defaultGoalId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.goalId !== undefined) {
|
||||||
|
return input.goalId ?? resolveFallbackGoalId(projectId, projectGoalId);
|
||||||
}
|
}
|
||||||
return goalId ?? null;
|
|
||||||
|
const currentFallbackGoalId = resolveFallbackGoalId(
|
||||||
|
input.currentProjectId,
|
||||||
|
input.currentProjectGoalId,
|
||||||
|
);
|
||||||
|
const nextFallbackGoalId = resolveFallbackGoalId(projectId, projectGoalId);
|
||||||
|
|
||||||
|
if (!input.currentGoalId) {
|
||||||
|
return nextFallbackGoalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.currentGoalId === currentFallbackGoalId) {
|
||||||
|
return nextFallbackGoalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.currentGoalId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ type IssueUserContextInput = {
|
||||||
createdAt: Date | string;
|
createdAt: Date | string;
|
||||||
updatedAt: Date | string;
|
updatedAt: Date | string;
|
||||||
};
|
};
|
||||||
|
type ProjectGoalReader = Pick<Db, "select">;
|
||||||
|
|
||||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||||
if (actorRunId) return checkoutRunId === actorRunId;
|
if (actorRunId) return checkoutRunId === actorRunId;
|
||||||
|
|
@ -113,6 +114,20 @@ function escapeLikePattern(value: string): string {
|
||||||
return value.replace(/[\\%_]/g, "\\$&");
|
return value.replace(/[\\%_]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getProjectDefaultGoalId(
|
||||||
|
db: ProjectGoalReader,
|
||||||
|
companyId: string,
|
||||||
|
projectId: string | null | undefined,
|
||||||
|
) {
|
||||||
|
if (!projectId) return null;
|
||||||
|
const row = await db
|
||||||
|
.select({ goalId: projects.goalId })
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row?.goalId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function touchedByUserCondition(companyId: string, userId: string) {
|
function touchedByUserCondition(companyId: string, userId: string) {
|
||||||
return sql<boolean>`
|
return sql<boolean>`
|
||||||
(
|
(
|
||||||
|
|
@ -744,6 +759,7 @@ export function issueService(db: Db) {
|
||||||
}
|
}
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||||
|
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||||
let executionWorkspaceSettings =
|
let executionWorkspaceSettings =
|
||||||
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
||||||
if (executionWorkspaceSettings == null && issueData.projectId) {
|
if (executionWorkspaceSettings == null && issueData.projectId) {
|
||||||
|
|
@ -795,6 +811,7 @@ export function issueService(db: Db) {
|
||||||
goalId: resolveIssueGoalId({
|
goalId: resolveIssueGoalId({
|
||||||
projectId: issueData.projectId,
|
projectId: issueData.projectId,
|
||||||
goalId: issueData.goalId,
|
goalId: issueData.goalId,
|
||||||
|
projectGoalId,
|
||||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||||
}),
|
}),
|
||||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||||
|
|
@ -895,11 +912,21 @@ export function issueService(db: Db) {
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
||||||
|
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
|
||||||
|
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
|
||||||
|
getProjectDefaultGoalId(
|
||||||
|
tx,
|
||||||
|
existing.companyId,
|
||||||
|
issueData.projectId !== undefined ? issueData.projectId : existing.projectId,
|
||||||
|
),
|
||||||
|
]);
|
||||||
patch.goalId = resolveNextIssueGoalId({
|
patch.goalId = resolveNextIssueGoalId({
|
||||||
currentProjectId: existing.projectId,
|
currentProjectId: existing.projectId,
|
||||||
currentGoalId: existing.goalId,
|
currentGoalId: existing.goalId,
|
||||||
|
currentProjectGoalId,
|
||||||
projectId: issueData.projectId,
|
projectId: issueData.projectId,
|
||||||
goalId: issueData.goalId,
|
goalId: issueData.goalId,
|
||||||
|
projectGoalId: nextProjectGoalId,
|
||||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||||
});
|
});
|
||||||
const updated = await tx
|
const updated = await tx
|
||||||
|
|
|
||||||
11
ui/README.md
Normal file
11
ui/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# @paperclipai/ui
|
||||||
|
|
||||||
|
Published static assets for the Paperclip board UI.
|
||||||
|
|
||||||
|
## What gets published
|
||||||
|
|
||||||
|
The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies.
|
||||||
|
|
||||||
|
## Typical use
|
||||||
|
|
||||||
|
Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`.
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
{
|
{
|
||||||
"name": "@paperclipai/ui",
|
"name": "@paperclipai/ui",
|
||||||
"version": "0.0.1",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"description": "Prebuilt Paperclip board UI assets.",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/paperclipai/paperclip",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/paperclipai/paperclip",
|
||||||
|
"directory": "ui"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b",
|
||||||
|
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
||||||
|
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
|
||||||
|
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
|
||||||
|
|
@ -26,53 +26,13 @@ import {
|
||||||
thematicBreakPlugin,
|
thematicBreakPlugin,
|
||||||
type RealmPlugin,
|
type RealmPlugin,
|
||||||
} from "@mdxeditor/editor";
|
} from "@mdxeditor/editor";
|
||||||
import { LinkNode, type LinkAttributes } from "@lexical/link";
|
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
|
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||||
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
|
|
||||||
|
|
||||||
class MentionAwareLinkNode extends LinkNode {
|
|
||||||
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
|
|
||||||
return new MentionAwareLinkNode(
|
|
||||||
node.getURL(),
|
|
||||||
{
|
|
||||||
rel: node.getRel(),
|
|
||||||
target: node.getTarget(),
|
|
||||||
title: node.getTitle(),
|
|
||||||
},
|
|
||||||
node.getKey(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
|
|
||||||
super(url, attributes, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitizeUrl(url: string): string {
|
|
||||||
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
|
|
||||||
return super.sanitizeUrl(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionAwareLinkNodeReplacement = {
|
|
||||||
replace: LinkNode,
|
|
||||||
with: (node: LinkNode) =>
|
|
||||||
new MentionAwareLinkNode(
|
|
||||||
node.getURL(),
|
|
||||||
{
|
|
||||||
rel: node.getRel(),
|
|
||||||
target: node.getTarget(),
|
|
||||||
title: node.getTitle(),
|
|
||||||
},
|
|
||||||
node.getKey(),
|
|
||||||
),
|
|
||||||
withKlass: MentionAwareLinkNode,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/* ---- Mention types ---- */
|
/* ---- Mention types ---- */
|
||||||
|
|
||||||
export interface MentionOption {
|
export interface MentionOption {
|
||||||
|
|
@ -590,7 +550,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
additionalLexicalNodes={[mentionAwareLinkNodeReplacement]}
|
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { companiesApi } from "../api/companies";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,6 +25,11 @@ import {
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
import { defaultCreateValues } from "./agent-config-defaults";
|
||||||
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||||
|
import {
|
||||||
|
buildOnboardingIssuePayload,
|
||||||
|
buildOnboardingProjectPayload,
|
||||||
|
selectDefaultCompanyGoalId
|
||||||
|
} from "../lib/onboarding-launch";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
|
@ -144,7 +150,11 @@ export function OnboardingWizard() {
|
||||||
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||||
|
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
|
||||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -160,6 +170,10 @@ export function OnboardingWizard() {
|
||||||
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||||
setCreatedCompanyId(cId);
|
setCreatedCompanyId(cId);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
|
setCreatedProjectId(null);
|
||||||
|
setCreatedAgentId(null);
|
||||||
|
setCreatedIssueRef(null);
|
||||||
}, [
|
}, [
|
||||||
effectiveOnboardingOpen,
|
effectiveOnboardingOpen,
|
||||||
effectiveOnboardingOptions.companyId,
|
effectiveOnboardingOptions.companyId,
|
||||||
|
|
@ -284,7 +298,9 @@ export function OnboardingWizard() {
|
||||||
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
|
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
|
||||||
setCreatedCompanyId(null);
|
setCreatedCompanyId(null);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
setCreatedAgentId(null);
|
setCreatedAgentId(null);
|
||||||
|
setCreatedProjectId(null);
|
||||||
setCreatedIssueRef(null);
|
setCreatedIssueRef(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +387,7 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
if (companyGoal.trim()) {
|
if (companyGoal.trim()) {
|
||||||
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
||||||
await goalsApi.create(company.id, {
|
const goal = await goalsApi.create(company.id, {
|
||||||
title: parsedGoal.title,
|
title: parsedGoal.title,
|
||||||
...(parsedGoal.description
|
...(parsedGoal.description
|
||||||
? { description: parsedGoal.description }
|
? { description: parsedGoal.description }
|
||||||
|
|
@ -379,9 +395,12 @@ export function OnboardingWizard() {
|
||||||
level: "company",
|
level: "company",
|
||||||
status: "active"
|
status: "active"
|
||||||
});
|
});
|
||||||
|
setCreatedCompanyGoalId(goal.id);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.goals.list(company.id)
|
queryKey: queryKeys.goals.list(company.id)
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setCreatedCompanyGoalId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
|
@ -522,16 +541,38 @@ export function OnboardingWizard() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
let goalId = createdCompanyGoalId;
|
||||||
|
if (!goalId) {
|
||||||
|
const goals = await goalsApi.list(createdCompanyId);
|
||||||
|
goalId = selectDefaultCompanyGoalId(goals);
|
||||||
|
setCreatedCompanyGoalId(goalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectId = createdProjectId;
|
||||||
|
if (!projectId) {
|
||||||
|
const project = await projectsApi.create(
|
||||||
|
createdCompanyId,
|
||||||
|
buildOnboardingProjectPayload(goalId)
|
||||||
|
);
|
||||||
|
projectId = project.id;
|
||||||
|
setCreatedProjectId(projectId);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.projects.list(createdCompanyId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let issueRef = createdIssueRef;
|
let issueRef = createdIssueRef;
|
||||||
if (!issueRef) {
|
if (!issueRef) {
|
||||||
const issue = await issuesApi.create(createdCompanyId, {
|
const issue = await issuesApi.create(
|
||||||
title: taskTitle.trim(),
|
createdCompanyId,
|
||||||
...(taskDescription.trim()
|
buildOnboardingIssuePayload({
|
||||||
? { description: taskDescription.trim() }
|
title: taskTitle,
|
||||||
: {}),
|
description: taskDescription,
|
||||||
assigneeAgentId: createdAgentId,
|
assigneeAgentId: createdAgentId,
|
||||||
status: "todo"
|
projectId,
|
||||||
});
|
goalId
|
||||||
|
})
|
||||||
|
);
|
||||||
issueRef = issue.identifier ?? issue.id;
|
issueRef = issue.identifier ?? issue.id;
|
||||||
setCreatedIssueRef(issueRef);
|
setCreatedIssueRef(issueRef);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: baseline;
|
vertical-align: middle;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
@ -746,7 +746,7 @@ a.paperclip-project-mention-chip {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: baseline;
|
vertical-align: middle;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ describe("inbox helpers", () => {
|
||||||
}).map((item) => {
|
}).map((item) => {
|
||||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||||
|
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
|
||||||
return `run:${item.run.id}`;
|
return `run:${item.run.id}`;
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|
@ -305,6 +306,37 @@ describe("inbox helpers", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mixes join requests into the inbox feed by most recent activity", () => {
|
||||||
|
const issue = makeIssue("1", true);
|
||||||
|
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||||
|
|
||||||
|
const joinRequest = makeJoinRequest("join-1");
|
||||||
|
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
|
||||||
|
|
||||||
|
const approval = makeApprovalWithTimestamps(
|
||||||
|
"approval-oldest",
|
||||||
|
"pending",
|
||||||
|
"2026-03-11T02:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getInboxWorkItems({
|
||||||
|
issues: [issue],
|
||||||
|
approvals: [approval],
|
||||||
|
joinRequests: [joinRequest],
|
||||||
|
}).map((item) => {
|
||||||
|
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||||
|
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||||
|
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
|
||||||
|
return `run:${item.run.id}`;
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"issue:1",
|
||||||
|
"join:join-1",
|
||||||
|
"approval:approval-oldest",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("can include sections on recent without forcing them to be unread", () => {
|
it("can include sections on recent without forcing them to be unread", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldShowInboxSection({
|
shouldShowInboxSection({
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ export type InboxWorkItem =
|
||||||
kind: "failed_run";
|
kind: "failed_run";
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
run: HeartbeatRun;
|
run: HeartbeatRun;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "join_request";
|
||||||
|
timestamp: number;
|
||||||
|
joinRequest: JoinRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface InboxBadgeData {
|
export interface InboxBadgeData {
|
||||||
|
|
@ -152,10 +157,12 @@ export function getInboxWorkItems({
|
||||||
issues,
|
issues,
|
||||||
approvals,
|
approvals,
|
||||||
failedRuns = [],
|
failedRuns = [],
|
||||||
|
joinRequests = [],
|
||||||
}: {
|
}: {
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
approvals: Approval[];
|
approvals: Approval[];
|
||||||
failedRuns?: HeartbeatRun[];
|
failedRuns?: HeartbeatRun[];
|
||||||
|
joinRequests?: JoinRequest[];
|
||||||
}): InboxWorkItem[] {
|
}): InboxWorkItem[] {
|
||||||
return [
|
return [
|
||||||
...issues.map((issue) => ({
|
...issues.map((issue) => ({
|
||||||
|
|
@ -173,6 +180,11 @@ export function getInboxWorkItems({
|
||||||
timestamp: normalizeTimestamp(run.createdAt),
|
timestamp: normalizeTimestamp(run.createdAt),
|
||||||
run,
|
run,
|
||||||
})),
|
})),
|
||||||
|
...joinRequests.map((joinRequest) => ({
|
||||||
|
kind: "join_request" as const,
|
||||||
|
timestamp: normalizeTimestamp(joinRequest.createdAt),
|
||||||
|
joinRequest,
|
||||||
|
})),
|
||||||
].sort((a, b) => {
|
].sort((a, b) => {
|
||||||
const timestampDiff = b.timestamp - a.timestamp;
|
const timestampDiff = b.timestamp - a.timestamp;
|
||||||
if (timestampDiff !== 0) return timestampDiff;
|
if (timestampDiff !== 0) return timestampDiff;
|
||||||
|
|
|
||||||
50
ui/src/lib/mention-aware-link-node.test.ts
Normal file
50
ui/src/lib/mention-aware-link-node.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { $createLinkNode } from "@lexical/link";
|
||||||
|
import { createEditor } from "lexical";
|
||||||
|
import {
|
||||||
|
MentionAwareLinkNode,
|
||||||
|
getMentionAwareLinkNodeInit,
|
||||||
|
mentionAwareLinkNodeReplacement,
|
||||||
|
} from "./mention-aware-link-node";
|
||||||
|
|
||||||
|
function createTestEditor() {
|
||||||
|
return createEditor({
|
||||||
|
namespace: "mention-aware-link-node-test",
|
||||||
|
nodes: [MentionAwareLinkNode, mentionAwareLinkNodeReplacement],
|
||||||
|
onError(error: Error) {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getMentionAwareLinkNodeInit", () => {
|
||||||
|
it("copies link attributes without carrying over a node key", () => {
|
||||||
|
const init = getMentionAwareLinkNodeInit({
|
||||||
|
getURL: () => "agent://agent-123",
|
||||||
|
getRel: () => "noreferrer",
|
||||||
|
getTarget: () => "_blank",
|
||||||
|
getTitle: () => "Agent mention",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(init)).toEqual(["url", "attributes"]);
|
||||||
|
expect(init).toEqual({
|
||||||
|
url: "agent://agent-123",
|
||||||
|
attributes: {
|
||||||
|
rel: "noreferrer",
|
||||||
|
target: "_blank",
|
||||||
|
title: "Agent mention",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces LinkNode creation with MentionAwareLinkNode without throwing", () => {
|
||||||
|
const editor = createTestEditor();
|
||||||
|
let created: unknown;
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
created = $createLinkNode("agent://agent-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toBeInstanceOf(MentionAwareLinkNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
ui/src/lib/mention-aware-link-node.ts
Normal file
67
ui/src/lib/mention-aware-link-node.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import {
|
||||||
|
LinkNode,
|
||||||
|
type LinkAttributes,
|
||||||
|
type SerializedLinkNode,
|
||||||
|
} from "@lexical/link";
|
||||||
|
|
||||||
|
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
|
||||||
|
|
||||||
|
export class MentionAwareLinkNode extends LinkNode {
|
||||||
|
static getType(): string {
|
||||||
|
return "mention-aware-link";
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
|
||||||
|
return new MentionAwareLinkNode(
|
||||||
|
node.getURL(),
|
||||||
|
{
|
||||||
|
rel: node.getRel(),
|
||||||
|
target: node.getTarget(),
|
||||||
|
title: node.getTitle(),
|
||||||
|
},
|
||||||
|
node.getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedLinkNode): MentionAwareLinkNode {
|
||||||
|
return new MentionAwareLinkNode(
|
||||||
|
serializedNode.url ?? "",
|
||||||
|
{
|
||||||
|
rel: serializedNode.rel ?? null,
|
||||||
|
target: serializedNode.target ?? null,
|
||||||
|
title: serializedNode.title ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
|
||||||
|
super(url, attributes, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeUrl(url: string): string {
|
||||||
|
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
|
||||||
|
return super.sanitizeUrl(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionAwareLinkSource = Pick<LinkNode, "getURL" | "getRel" | "getTarget" | "getTitle">;
|
||||||
|
|
||||||
|
export function getMentionAwareLinkNodeInit(node: MentionAwareLinkSource) {
|
||||||
|
return {
|
||||||
|
url: node.getURL(),
|
||||||
|
attributes: {
|
||||||
|
rel: node.getRel(),
|
||||||
|
target: node.getTarget(),
|
||||||
|
title: node.getTitle(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mentionAwareLinkNodeReplacement = {
|
||||||
|
replace: LinkNode,
|
||||||
|
with: (node: LinkNode) => {
|
||||||
|
const { url, attributes } = getMentionAwareLinkNodeInit(node);
|
||||||
|
return new MentionAwareLinkNode(url, attributes);
|
||||||
|
},
|
||||||
|
withKlass: MentionAwareLinkNode,
|
||||||
|
} as const;
|
||||||
131
ui/src/lib/onboarding-launch.test.ts
Normal file
131
ui/src/lib/onboarding-launch.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildOnboardingIssuePayload,
|
||||||
|
buildOnboardingProjectPayload,
|
||||||
|
selectDefaultCompanyGoalId,
|
||||||
|
} from "./onboarding-launch";
|
||||||
|
|
||||||
|
describe("selectDefaultCompanyGoalId", () => {
|
||||||
|
it("prefers the earliest active root company goal", () => {
|
||||||
|
expect(
|
||||||
|
selectDefaultCompanyGoalId([
|
||||||
|
{
|
||||||
|
id: "team-goal",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Nested",
|
||||||
|
description: null,
|
||||||
|
level: "team",
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-04T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-04T00:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goal-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Later active root",
|
||||||
|
description: null,
|
||||||
|
level: "company",
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-03T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-03T00:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goal-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Earliest active root",
|
||||||
|
description: null,
|
||||||
|
level: "company",
|
||||||
|
status: "active",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe("goal-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the earliest root company goal when none are active", () => {
|
||||||
|
expect(
|
||||||
|
selectDefaultCompanyGoalId([
|
||||||
|
{
|
||||||
|
id: "goal-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Cancelled root",
|
||||||
|
description: null,
|
||||||
|
level: "company",
|
||||||
|
status: "cancelled",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-03T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-03T00:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goal-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Earliest root",
|
||||||
|
description: null,
|
||||||
|
level: "company",
|
||||||
|
status: "planned",
|
||||||
|
parentId: null,
|
||||||
|
ownerAgentId: null,
|
||||||
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe("goal-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onboarding launch payloads", () => {
|
||||||
|
it("links the onboarding project and first issue to the selected goal", () => {
|
||||||
|
expect(buildOnboardingProjectPayload("goal-1")).toEqual({
|
||||||
|
name: "Onboarding",
|
||||||
|
status: "in_progress",
|
||||||
|
goalIds: ["goal-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildOnboardingIssuePayload({
|
||||||
|
title: " Hire your first engineer ",
|
||||||
|
description: " Kick off the hiring plan ",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
title: "Hire your first engineer",
|
||||||
|
description: "Kick off the hiring plan",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits goal links when no default company goal exists", () => {
|
||||||
|
expect(buildOnboardingProjectPayload(null)).toEqual({
|
||||||
|
name: "Onboarding",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildOnboardingIssuePayload({
|
||||||
|
title: "Task",
|
||||||
|
description: "",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
title: "Task",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
53
ui/src/lib/onboarding-launch.ts
Normal file
53
ui/src/lib/onboarding-launch.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { Goal } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export const ONBOARDING_PROJECT_NAME = "Onboarding";
|
||||||
|
|
||||||
|
function goalCreatedAt(goal: Goal) {
|
||||||
|
const createdAt = goal.createdAt instanceof Date ? goal.createdAt : new Date(goal.createdAt);
|
||||||
|
return Number.isNaN(createdAt.getTime()) ? 0 : createdAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEarliestGoal(goals: Goal[]) {
|
||||||
|
return [...goals].sort((a, b) => goalCreatedAt(a) - goalCreatedAt(b))[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectDefaultCompanyGoalId(goals: Goal[]): string | null {
|
||||||
|
const companyGoals = goals.filter((goal) => goal.level === "company");
|
||||||
|
const rootGoals = companyGoals.filter((goal) => !goal.parentId);
|
||||||
|
const activeRootGoals = rootGoals.filter((goal) => goal.status === "active");
|
||||||
|
|
||||||
|
return (
|
||||||
|
pickEarliestGoal(activeRootGoals)?.id ??
|
||||||
|
pickEarliestGoal(rootGoals)?.id ??
|
||||||
|
pickEarliestGoal(companyGoals)?.id ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOnboardingProjectPayload(goalId: string | null) {
|
||||||
|
return {
|
||||||
|
name: ONBOARDING_PROJECT_NAME,
|
||||||
|
status: "in_progress" as const,
|
||||||
|
...(goalId ? { goalIds: [goalId] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOnboardingIssuePayload(input: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
projectId: string;
|
||||||
|
goalId: string | null;
|
||||||
|
}) {
|
||||||
|
const title = input.title.trim();
|
||||||
|
const description = input.description.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
...(input.goalId ? { goalId: input.goalId } : {}),
|
||||||
|
status: "todo" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -998,6 +998,7 @@ export function AgentDetail() {
|
||||||
|
|
||||||
{activeView === "instructions" && (
|
{activeView === "instructions" && (
|
||||||
<PromptsTab
|
<PromptsTab
|
||||||
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
companyId={resolvedCompanyId ?? undefined}
|
companyId={resolvedCompanyId ?? undefined}
|
||||||
onDirtyChange={setConfigDirty}
|
onDirtyChange={setConfigDirty}
|
||||||
|
|
@ -1617,6 +1618,20 @@ function PromptsTab({
|
||||||
selectedFile: string;
|
selectedFile: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedFile("AGENTS.md");
|
||||||
|
setShowFilePanel(false);
|
||||||
|
setDraft(null);
|
||||||
|
setBundleDraft(null);
|
||||||
|
setNewFilePath("");
|
||||||
|
setShowNewFileInput(false);
|
||||||
|
setPendingFiles([]);
|
||||||
|
setExpandedDirs(new Set());
|
||||||
|
setAwaitingRefresh(false);
|
||||||
|
lastFileVersionRef.current = null;
|
||||||
|
externalBundleRef.current = null;
|
||||||
|
}, [agent.id]);
|
||||||
|
|
||||||
const isLocal =
|
const isLocal =
|
||||||
agent.adapterType === "claude_local" ||
|
agent.adapterType === "claude_local" ||
|
||||||
agent.adapterType === "codex_local" ||
|
agent.adapterType === "codex_local" ||
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
XCircle,
|
XCircle,
|
||||||
X,
|
X,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
|
|
@ -61,7 +62,6 @@ type InboxCategoryFilter =
|
||||||
| "alerts";
|
| "alerts";
|
||||||
type SectionKey =
|
type SectionKey =
|
||||||
| "work_items"
|
| "work_items"
|
||||||
| "join_requests"
|
|
||||||
| "alerts";
|
| "alerts";
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
|
|
@ -281,6 +281,84 @@ function ApprovalInboxRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JoinRequestInboxRow({
|
||||||
|
joinRequest,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
joinRequest: JoinRequest;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const label =
|
||||||
|
joinRequest.requestType === "human"
|
||||||
|
? "Human join request"
|
||||||
|
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
|
||||||
|
<div className="flex items-start gap-2 sm:items-center">
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||||
|
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
|
||||||
|
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}</span>
|
||||||
|
{joinRequest.adapterType && <span>adapter: {joinRequest.adapterType}</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3"
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2 sm:hidden">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3"
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Inbox() {
|
export function Inbox() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
@ -431,14 +509,22 @@ export function Inbox() {
|
||||||
return failedRuns;
|
return failedRuns;
|
||||||
}, [failedRuns, tab, showFailedRunsCategory]);
|
}, [failedRuns, tab, showFailedRunsCategory]);
|
||||||
|
|
||||||
|
const joinRequestsForTab = useMemo(() => {
|
||||||
|
if (tab === "all" && !showJoinRequestsCategory) return [];
|
||||||
|
if (tab === "recent") return joinRequests;
|
||||||
|
if (tab === "unread") return joinRequests;
|
||||||
|
return joinRequests;
|
||||||
|
}, [joinRequests, tab, showJoinRequestsCategory]);
|
||||||
|
|
||||||
const workItemsToRender = useMemo(
|
const workItemsToRender = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getInboxWorkItems({
|
getInboxWorkItems({
|
||||||
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||||
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||||
failedRuns: failedRunsForTab,
|
failedRuns: failedRunsForTab,
|
||||||
|
joinRequests: joinRequestsForTab,
|
||||||
}),
|
}),
|
||||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
|
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentName = (id: string | null) => {
|
const agentName = (id: string | null) => {
|
||||||
|
|
@ -602,10 +688,7 @@ export function Inbox() {
|
||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
|
||||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||||
const showJoinRequestsSection =
|
|
||||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
|
||||||
const showAlertsSection = shouldShowInboxSection({
|
const showAlertsSection = shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems: hasAlerts,
|
hasItems: hasAlerts,
|
||||||
|
|
@ -616,7 +699,6 @@ export function Inbox() {
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
|
||||||
showWorkItemsSection ? "work_items" : null,
|
showWorkItemsSection ? "work_items" : null,
|
||||||
].filter((key): key is SectionKey => key !== null);
|
].filter((key): key is SectionKey => key !== null);
|
||||||
|
|
||||||
|
|
@ -757,6 +839,18 @@ export function Inbox() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind === "join_request") {
|
||||||
|
return (
|
||||||
|
<JoinRequestInboxRow
|
||||||
|
key={`join:${item.joinRequest.id}`}
|
||||||
|
joinRequest={item.joinRequest}
|
||||||
|
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||||
|
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||||
|
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const issue = item.issue;
|
const issue = item.issue;
|
||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
|
|
@ -806,61 +900,6 @@ export function Inbox() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showJoinRequestsSection && (
|
|
||||||
<>
|
|
||||||
{showSeparatorBefore("join_requests") && <Separator />}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Join Requests
|
|
||||||
</h3>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{joinRequests.map((joinRequest) => (
|
|
||||||
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{joinRequest.requestType === "human"
|
|
||||||
? "Human join request"
|
|
||||||
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
|
|
||||||
</p>
|
|
||||||
{joinRequest.requestEmailSnapshot && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
email: {joinRequest.requestEmailSnapshot}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{joinRequest.adapterType && (
|
|
||||||
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
|
||||||
onClick={() => rejectJoinMutation.mutate(joinRequest)}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
|
||||||
onClick={() => approveJoinMutation.mutate(joinRequest)}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{showAlertsSection && (
|
{showAlertsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("alerts") && <Separator />}
|
{showSeparatorBefore("alerts") && <Separator />}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue