mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Add routine support to recurring task portability
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -28,7 +28,7 @@ These define the contract between server, CLI, and UI.
|
|||||||
|
|
||||||
| File | What it defines |
|
| File | What it defines |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, companies. |
|
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. |
|
||||||
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
|
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
|
||||||
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
|
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
|
||||||
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
|
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
|
||||||
@@ -37,7 +37,8 @@ These define the contract between server, CLI, and UI.
|
|||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, task recurrence parsing, and package README generation. References `agentcompanies/v1` version string. |
|
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. |
|
||||||
|
| `server/src/services/routines.ts` | Paperclip routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. |
|
||||||
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
|
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
|
||||||
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
|
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
|
|||||||
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
|
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
|
||||||
| `TASK.md` frontmatter & body | `company-portability.ts` |
|
| `TASK.md` frontmatter & body | `company-portability.ts` |
|
||||||
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
|
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
|
||||||
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
|
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
|
||||||
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
|
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
|
||||||
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
|
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
|
||||||
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |
|
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |
|
||||||
|
|||||||
@@ -860,11 +860,14 @@ Export/import behavior in V1:
|
|||||||
|
|
||||||
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
|
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
|
||||||
- projects and starter tasks are opt-in export content rather than default package content
|
- projects and starter tasks are opt-in export content rather than default package content
|
||||||
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication)
|
- recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml`
|
||||||
|
- Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues
|
||||||
|
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml`
|
||||||
- export never includes secret values; env inputs are reported as portable declarations instead
|
- export never includes secret values; env inputs are reported as portable declarations instead
|
||||||
- import supports target modes:
|
- import supports target modes:
|
||||||
- create a new company
|
- create a new company
|
||||||
- import into an existing company
|
- import into an existing company
|
||||||
|
- import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids
|
||||||
- import supports collision strategies: `rename`, `skip`, `replace`
|
- import supports collision strategies: `rename`, `skip`, `replace`
|
||||||
- import supports preview (dry-run) before apply
|
- import supports preview (dry-run) before apply
|
||||||
- GitHub imports warn on unpinned refs instead of blocking
|
- GitHub imports warn on unpinned refs instead of blocking
|
||||||
|
|||||||
@@ -253,17 +253,7 @@ owner: cto
|
|||||||
name: Monday Review
|
name: Monday Review
|
||||||
assignee: ceo
|
assignee: ceo
|
||||||
project: q2-launch
|
project: q2-launch
|
||||||
schedule:
|
recurring: true
|
||||||
timezone: America/Chicago
|
|
||||||
startsAt: 2026-03-16T09:00:00-05:00
|
|
||||||
recurrence:
|
|
||||||
frequency: weekly
|
|
||||||
interval: 1
|
|
||||||
weekdays:
|
|
||||||
- monday
|
|
||||||
time:
|
|
||||||
hour: 9
|
|
||||||
minute: 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Semantics
|
### Semantics
|
||||||
@@ -271,58 +261,30 @@ schedule:
|
|||||||
- body content is the canonical markdown task description
|
- body content is the canonical markdown task description
|
||||||
- `assignee` should reference an agent slug inside the package
|
- `assignee` should reference an agent slug inside the package
|
||||||
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
|
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
|
||||||
- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence
|
- `recurring: true` marks the task as ongoing recurring work instead of a one-time starter task
|
||||||
|
- tasks are intentionally basic seed work: title, markdown body, assignee, project linkage, and optional `recurring: true`
|
||||||
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
|
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
|
||||||
|
|
||||||
### Scheduling
|
### Recurring Tasks
|
||||||
|
|
||||||
The scheduling model is intentionally lightweight. It should cover common recurring patterns such as:
|
- the base package only needs to say whether a task is recurring
|
||||||
|
- vendors may attach the actual schedule / trigger / runtime fidelity in a vendor extension such as `.paperclip.yaml`
|
||||||
|
- this keeps `TASK.md` portable while still allowing richer runtime systems to round-trip their own automation details
|
||||||
|
- legacy packages may still use `schedule.recurrence` during transition, but exporters should prefer `recurring: true`
|
||||||
|
|
||||||
- every 6 hours
|
Example Paperclip extension:
|
||||||
- every weekday at 9:00
|
|
||||||
- every Monday morning
|
|
||||||
- every month on the 1st
|
|
||||||
- every first Monday of the month
|
|
||||||
- every year on January 1
|
|
||||||
|
|
||||||
Suggested shape:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schedule:
|
routines:
|
||||||
timezone: America/Chicago
|
monday-review:
|
||||||
startsAt: 2026-03-14T09:00:00-05:00
|
triggers:
|
||||||
recurrence:
|
- kind: schedule
|
||||||
frequency: hourly | daily | weekly | monthly | yearly
|
cronExpression: "0 9 * * 1"
|
||||||
interval: 1
|
timezone: America/Chicago
|
||||||
weekdays:
|
|
||||||
- monday
|
|
||||||
- wednesday
|
|
||||||
monthDays:
|
|
||||||
- 1
|
|
||||||
- 15
|
|
||||||
ordinalWeekdays:
|
|
||||||
- weekday: monday
|
|
||||||
ordinal: 1
|
|
||||||
months:
|
|
||||||
- 1
|
|
||||||
- 6
|
|
||||||
time:
|
|
||||||
hour: 9
|
|
||||||
minute: 0
|
|
||||||
until: 2026-12-31T23:59:59-06:00
|
|
||||||
count: 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
- vendors should ignore unknown recurring-task extensions they do not understand
|
||||||
|
- vendors importing legacy `schedule.recurrence` data may translate it into their own runtime trigger model, but new exports should prefer the simpler `recurring: true` base field
|
||||||
- `timezone` should use an IANA timezone like `America/Chicago`
|
|
||||||
- `startsAt` anchors the first occurrence
|
|
||||||
- `frequency` and `interval` are the only required recurrence fields
|
|
||||||
- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules
|
|
||||||
- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last”
|
|
||||||
- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable
|
|
||||||
- `until` and `count` are optional recurrence end bounds
|
|
||||||
- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above
|
|
||||||
|
|
||||||
## 11. SKILL.md Compatibility
|
## 11. SKILL.md Compatibility
|
||||||
|
|
||||||
@@ -449,7 +411,7 @@ Suggested import UI behavior:
|
|||||||
- selecting an agent auto-selects required docs and referenced skills
|
- selecting an agent auto-selects required docs and referenced skills
|
||||||
- selecting a team auto-selects its subtree
|
- selecting a team auto-selects its subtree
|
||||||
- selecting a project auto-selects its included tasks
|
- selecting a project auto-selects its included tasks
|
||||||
- selecting a recurring task should surface its schedule before import
|
- selecting a recurring task should make it clear that the import target is a routine / automation, not a one-time task
|
||||||
- selecting referenced third-party content shows attribution, license, and fetch policy
|
- selecting referenced third-party content shows attribution, license, and fetch policy
|
||||||
|
|
||||||
## 15. Vendor Extensions
|
## 15. Vendor Extensions
|
||||||
@@ -502,6 +464,12 @@ agents:
|
|||||||
kind: plain
|
kind: plain
|
||||||
requirement: optional
|
requirement: optional
|
||||||
default: claude
|
default: claude
|
||||||
|
routines:
|
||||||
|
monday-review:
|
||||||
|
triggers:
|
||||||
|
- kind: schedule
|
||||||
|
cronExpression: "0 9 * * 1"
|
||||||
|
timezone: America/Chicago
|
||||||
```
|
```
|
||||||
|
|
||||||
Additional rules for Paperclip exporters:
|
Additional rules for Paperclip exporters:
|
||||||
@@ -520,7 +488,7 @@ A compliant exporter should:
|
|||||||
- omit machine-local ids and timestamps
|
- omit machine-local ids and timestamps
|
||||||
- omit secret values
|
- omit secret values
|
||||||
- omit machine-specific paths
|
- omit machine-specific paths
|
||||||
- preserve task descriptions and recurrence definitions when exporting tasks
|
- preserve task descriptions and recurring-task declarations when exporting tasks
|
||||||
- omit empty/default fields
|
- omit empty/default fields
|
||||||
- default to the vendor-neutral base package
|
- default to the vendor-neutral base package
|
||||||
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
|
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
|
||||||
@@ -569,11 +537,11 @@ Paperclip can map this spec to its runtime model like this:
|
|||||||
- `TEAM.md` -> importable org subtree
|
- `TEAM.md` -> importable org subtree
|
||||||
- `AGENTS.md` -> agent identity and instructions
|
- `AGENTS.md` -> agent identity and instructions
|
||||||
- `PROJECT.md` -> starter project definition
|
- `PROJECT.md` -> starter project definition
|
||||||
- `TASK.md` -> starter issue/task definition, or automation template when recurrence is present
|
- `TASK.md` -> starter issue/task definition, or recurring task template when `recurring: true`
|
||||||
- `SKILL.md` -> imported skill package
|
- `SKILL.md` -> imported skill package
|
||||||
- `sources[]` -> provenance and pinned upstream refs
|
- `sources[]` -> provenance and pinned upstream refs
|
||||||
- Paperclip extension:
|
- Paperclip extension:
|
||||||
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity
|
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity
|
||||||
|
|
||||||
Inline Paperclip-only metadata that must live inside a shared markdown file should use:
|
Inline Paperclip-only metadata that must live inside a shared markdown file should use:
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ export type {
|
|||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
CompanyPortabilityProjectWorkspaceManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
|||||||
@@ -44,18 +44,52 @@ export interface CompanyPortabilityProjectManifestEntry {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
|
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
sourceType: string | null;
|
||||||
|
repoUrl: string | null;
|
||||||
|
repoRef: string | null;
|
||||||
|
defaultRef: string | null;
|
||||||
|
visibility: string | null;
|
||||||
|
setupCommand: string | null;
|
||||||
|
cleanupCommand: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
|
||||||
|
kind: string;
|
||||||
|
label: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
cronExpression: string | null;
|
||||||
|
timezone: string | null;
|
||||||
|
signingMode: string | null;
|
||||||
|
replayWindowSec: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityIssueRoutineManifestEntry {
|
||||||
|
concurrencyPolicy: string | null;
|
||||||
|
catchUpPolicy: string | null;
|
||||||
|
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityIssueManifestEntry {
|
export interface CompanyPortabilityIssueManifestEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
identifier: string | null;
|
identifier: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
projectSlug: string | null;
|
projectSlug: string | null;
|
||||||
|
projectWorkspaceKey: string | null;
|
||||||
assigneeAgentSlug: string | null;
|
assigneeAgentSlug: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
recurrence: Record<string, unknown> | null;
|
recurring: boolean;
|
||||||
|
routine: CompanyPortabilityIssueRoutineManifestEntry | null;
|
||||||
|
legacyRecurrence: Record<string, unknown> | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export type {
|
|||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
CompanyPortabilityProjectWorkspaceManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
|||||||
@@ -85,18 +85,50 @@ export const portabilityProjectManifestEntrySchema = z.object({
|
|||||||
color: z.string().nullable(),
|
color: z.string().nullable(),
|
||||||
status: z.string().nullable(),
|
status: z.string().nullable(),
|
||||||
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
|
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
|
||||||
|
workspaces: z.array(z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
sourceType: z.string().nullable(),
|
||||||
|
repoUrl: z.string().nullable(),
|
||||||
|
repoRef: z.string().nullable(),
|
||||||
|
defaultRef: z.string().nullable(),
|
||||||
|
visibility: z.string().nullable(),
|
||||||
|
setupCommand: z.string().nullable(),
|
||||||
|
cleanupCommand: z.string().nullable(),
|
||||||
|
metadata: z.record(z.unknown()).nullable(),
|
||||||
|
isPrimary: z.boolean(),
|
||||||
|
})).default([]),
|
||||||
metadata: z.record(z.unknown()).nullable(),
|
metadata: z.record(z.unknown()).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
|
||||||
|
kind: z.string().min(1),
|
||||||
|
label: z.string().nullable(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
cronExpression: z.string().nullable(),
|
||||||
|
timezone: z.string().nullable(),
|
||||||
|
signingMode: z.string().nullable(),
|
||||||
|
replayWindowSec: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portabilityIssueRoutineManifestEntrySchema = z.object({
|
||||||
|
concurrencyPolicy: z.string().nullable(),
|
||||||
|
catchUpPolicy: z.string().nullable(),
|
||||||
|
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const portabilityIssueManifestEntrySchema = z.object({
|
export const portabilityIssueManifestEntrySchema = z.object({
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
identifier: z.string().min(1).nullable(),
|
identifier: z.string().min(1).nullable(),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
projectSlug: z.string().min(1).nullable(),
|
projectSlug: z.string().min(1).nullable(),
|
||||||
|
projectWorkspaceKey: z.string().min(1).nullable(),
|
||||||
assigneeAgentSlug: z.string().min(1).nullable(),
|
assigneeAgentSlug: z.string().min(1).nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
recurrence: z.record(z.unknown()).nullable(),
|
recurring: z.boolean().default(false),
|
||||||
|
routine: portabilityIssueRoutineManifestEntrySchema.nullable(),
|
||||||
|
legacyRecurrence: z.record(z.unknown()).nullable(),
|
||||||
status: z.string().nullable(),
|
status: z.string().nullable(),
|
||||||
priority: z.string().nullable(),
|
priority: z.string().nullable(),
|
||||||
labelIds: z.array(z.string().min(1)).default([]),
|
labelIds: z.array(z.string().min(1)).default([]),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const projectSvc = {
|
|||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
createWorkspace: vi.fn(),
|
||||||
|
listWorkspaces: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueSvc = {
|
const issueSvc = {
|
||||||
@@ -34,6 +36,13 @@ const issueSvc = {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const routineSvc = {
|
||||||
|
list: vi.fn(),
|
||||||
|
getDetail: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
createTrigger: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const companySkillSvc = {
|
const companySkillSvc = {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
listFull: vi.fn(),
|
listFull: vi.fn(),
|
||||||
@@ -71,6 +80,10 @@ vi.mock("../services/issues.js", () => ({
|
|||||||
issueService: () => issueSvc,
|
issueService: () => issueSvc,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/routines.js", () => ({
|
||||||
|
routineService: () => routineSvc,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../services/company-skills.js", () => ({
|
vi.mock("../services/company-skills.js", () => ({
|
||||||
companySkillService: () => companySkillSvc,
|
companySkillService: () => companySkillSvc,
|
||||||
}));
|
}));
|
||||||
@@ -184,9 +197,62 @@ describe("company portability", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
projectSvc.list.mockResolvedValue([]);
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.createWorkspace.mockResolvedValue(null);
|
||||||
|
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||||
issueSvc.list.mockResolvedValue([]);
|
issueSvc.list.mockResolvedValue([]);
|
||||||
issueSvc.getById.mockResolvedValue(null);
|
issueSvc.getById.mockResolvedValue(null);
|
||||||
issueSvc.getByIdentifier.mockResolvedValue(null);
|
issueSvc.getByIdentifier.mockResolvedValue(null);
|
||||||
|
routineSvc.list.mockResolvedValue([]);
|
||||||
|
routineSvc.getDetail.mockImplementation(async (id: string) => {
|
||||||
|
const rows = await routineSvc.list();
|
||||||
|
return rows.find((row: { id: string }) => row.id === id) ?? null;
|
||||||
|
});
|
||||||
|
routineSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||||
|
id: "routine-created",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: input.projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
|
priority: input.priority ?? "medium",
|
||||||
|
status: input.status ?? "active",
|
||||||
|
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
|
||||||
|
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
routineSvc.createTrigger.mockImplementation(async (_routineId: string, input: Record<string, unknown>) => ({
|
||||||
|
id: "trigger-created",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-created",
|
||||||
|
kind: input.kind,
|
||||||
|
label: input.label ?? null,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
cronExpression: input.kind === "schedule" ? input.cronExpression ?? null : null,
|
||||||
|
timezone: input.kind === "schedule" ? input.timezone ?? null : null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: null,
|
||||||
|
secretId: null,
|
||||||
|
signingMode: input.kind === "webhook" ? input.signingMode ?? "bearer" : null,
|
||||||
|
replayWindowSec: input.kind === "webhook" ? input.replayWindowSec ?? 300 : null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
const companySkills = [
|
const companySkills = [
|
||||||
{
|
{
|
||||||
id: "skill-1",
|
id: "skill-1",
|
||||||
@@ -599,6 +665,200 @@ describe("company portability", () => {
|
|||||||
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports portable project workspace metadata and remaps it on import", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: "2026-03-31",
|
||||||
|
color: "#123456",
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: "workspace-1",
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "project_primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Main Repo",
|
||||||
|
sourceType: "git_repo",
|
||||||
|
cwd: "/Users/dotta/paperclip",
|
||||||
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||||
|
repoRef: "main",
|
||||||
|
defaultRef: "main",
|
||||||
|
visibility: "default",
|
||||||
|
setupCommand: "pnpm install",
|
||||||
|
cleanupCommand: "rm -rf .paperclip-tmp",
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: {
|
||||||
|
language: "typescript",
|
||||||
|
},
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Local Scratch",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/paperclip-local",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
visibility: "advanced",
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
isPrimary: false,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
issueSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
title: "Write launch task",
|
||||||
|
description: "Task body",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: {
|
||||||
|
mode: "shared_workspace",
|
||||||
|
},
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain("workspaces:");
|
||||||
|
expect(extension).toContain("main-repo:");
|
||||||
|
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||||
|
expect(extension).toContain('defaultProjectWorkspaceKey: "main-repo"');
|
||||||
|
expect(extension).toContain('projectWorkspaceKey: "main-repo"');
|
||||||
|
expect(extension).not.toContain("/Users/dotta/paperclip");
|
||||||
|
expect(extension).not.toContain("workspace-1");
|
||||||
|
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-imported",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
projectSvc.update.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||||
|
id: projectId,
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||||
|
id: "workspace-imported",
|
||||||
|
companyId: "company-imported",
|
||||||
|
projectId,
|
||||||
|
name: `${data.name ?? "Workspace"}`,
|
||||||
|
sourceType: `${data.sourceType ?? "git_repo"}`,
|
||||||
|
cwd: null,
|
||||||
|
repoUrl: typeof data.repoUrl === "string" ? data.repoUrl : null,
|
||||||
|
repoRef: typeof data.repoRef === "string" ? data.repoRef : null,
|
||||||
|
defaultRef: typeof data.defaultRef === "string" ? data.defaultRef : null,
|
||||||
|
visibility: `${data.visibility ?? "default"}`,
|
||||||
|
setupCommand: typeof data.setupCommand === "string" ? data.setupCommand : null,
|
||||||
|
cleanupCommand: typeof data.cleanupCommand === "string" ? data.cleanupCommand : null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||||
|
isPrimary: Boolean(data.isPrimary),
|
||||||
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
}));
|
||||||
|
issueSvc.create.mockResolvedValue({
|
||||||
|
id: "issue-imported",
|
||||||
|
title: "Write launch task",
|
||||||
|
});
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: exported.rootPath,
|
||||||
|
files: exported.files,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
mode: "new_company",
|
||||||
|
newCompanyName: "Imported Paperclip",
|
||||||
|
},
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(projectSvc.createWorkspace).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||||
|
name: "Main Repo",
|
||||||
|
sourceType: "git_repo",
|
||||||
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||||
|
repoRef: "main",
|
||||||
|
defaultRef: "main",
|
||||||
|
visibility: "default",
|
||||||
|
}));
|
||||||
|
expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||||
|
executionWorkspacePolicy: expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: "workspace-imported",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
projectId: "project-imported",
|
||||||
|
projectWorkspaceId: "workspace-imported",
|
||||||
|
title: "Write launch task",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
@@ -654,6 +914,360 @@ describe("company portability", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
routineSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "routine-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "Monday Review",
|
||||||
|
description: "Review pipeline health",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
priority: "high",
|
||||||
|
status: "paused",
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
id: "trigger-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-1",
|
||||||
|
kind: "schedule",
|
||||||
|
label: "Weekly cadence",
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: "public-1",
|
||||||
|
secretId: "secret-1",
|
||||||
|
signingMode: null,
|
||||||
|
replayWindowSec: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trigger-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-1",
|
||||||
|
kind: "webhook",
|
||||||
|
label: "External nudge",
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: null,
|
||||||
|
timezone: null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: "public-2",
|
||||||
|
secretId: "secret-2",
|
||||||
|
signingMode: "hmac_sha256",
|
||||||
|
replayWindowSec: 120,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastRun: null,
|
||||||
|
activeIssue: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(asTextFile(exported.files["tasks/monday-review/TASK.md"])).toContain('recurring: true');
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain("routines:");
|
||||||
|
expect(extension).toContain("monday-review:");
|
||||||
|
expect(extension).toContain('cronExpression: "0 9 * * 1"');
|
||||||
|
expect(extension).toContain('signingMode: "hmac_sha256"');
|
||||||
|
expect(extension).not.toContain("secretId");
|
||||||
|
expect(extension).not.toContain("publicId");
|
||||||
|
expect(exported.manifest.issues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "monday-review",
|
||||||
|
recurring: true,
|
||||||
|
status: "paused",
|
||||||
|
priority: "high",
|
||||||
|
routine: expect.objectContaining({
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
triggers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
|
||||||
|
expect.objectContaining({ kind: "webhook", enabled: false, signingMode: "hmac_sha256", replayWindowSec: 120 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports recurring task packages as routines instead of one-time issues", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.create.mockResolvedValue({
|
||||||
|
id: "agent-created",
|
||||||
|
name: "ClaudeCoder",
|
||||||
|
});
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-created",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
"COMPANY.md": [
|
||||||
|
"---",
|
||||||
|
'schema: "agentcompanies/v1"',
|
||||||
|
'name: "Imported Paperclip"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"agents/claudecoder/AGENTS.md": [
|
||||||
|
"---",
|
||||||
|
'name: "ClaudeCoder"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"You write code.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"projects/launch/PROJECT.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Launch"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
'project: "launch"',
|
||||||
|
'assignee: "claudecoder"',
|
||||||
|
"recurring: true",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
".paperclip.yaml": [
|
||||||
|
'schema: "paperclip/v1"',
|
||||||
|
"routines:",
|
||||||
|
" monday-review:",
|
||||||
|
' status: "paused"',
|
||||||
|
' priority: "high"',
|
||||||
|
' concurrencyPolicy: "always_enqueue"',
|
||||||
|
' catchUpPolicy: "enqueue_missed_with_cap"',
|
||||||
|
" triggers:",
|
||||||
|
" - kind: schedule",
|
||||||
|
' cronExpression: "0 9 * * 1"',
|
||||||
|
' timezone: "America/Chicago"',
|
||||||
|
' - kind: webhook',
|
||||||
|
' enabled: false',
|
||||||
|
' signingMode: "hmac_sha256"',
|
||||||
|
' replayWindowSec: 120',
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toEqual([]);
|
||||||
|
expect(preview.plan.issuePlans).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "monday-review",
|
||||||
|
reason: "Recurring task will be imported as a routine.",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
projectId: "project-created",
|
||||||
|
title: "Monday Review",
|
||||||
|
assigneeAgentId: "agent-created",
|
||||||
|
priority: "high",
|
||||||
|
status: "paused",
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "schedule",
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "webhook",
|
||||||
|
enabled: false,
|
||||||
|
signingMode: "hmac_sha256",
|
||||||
|
replayWindowSec: 120,
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates legacy schedule.recurrence imports into routine triggers", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.create.mockResolvedValue({
|
||||||
|
id: "agent-created",
|
||||||
|
name: "ClaudeCoder",
|
||||||
|
});
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-created",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||||
|
"agents/claudecoder/AGENTS.md": ['---', 'name: "ClaudeCoder"', "---", "", "You write code.", ""].join("\n"),
|
||||||
|
"projects/launch/PROJECT.md": ['---', 'name: "Launch"', "---", ""].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
'project: "launch"',
|
||||||
|
'assignee: "claudecoder"',
|
||||||
|
"schedule:",
|
||||||
|
' timezone: "America/Chicago"',
|
||||||
|
' startsAt: "2026-03-16T09:00:00-05:00"',
|
||||||
|
" recurrence:",
|
||||||
|
' frequency: "weekly"',
|
||||||
|
" interval: 1",
|
||||||
|
" weekdays:",
|
||||||
|
' - "monday"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toEqual([]);
|
||||||
|
expect(preview.manifest.issues[0]).toEqual(expect.objectContaining({
|
||||||
|
recurring: true,
|
||||||
|
legacyRecurrence: expect.objectContaining({ frequency: "weekly" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "schedule",
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags recurring task imports that are missing routine-required fields", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: "paperclip-demo",
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
"recurring: true",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { company: true, agents: false, projects: false, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toContain("Recurring task monday-review must declare a project to import as a routine.");
|
||||||
|
expect(preview.errors).toContain("Recurring task monday-review must declare an assignee to import as a routine.");
|
||||||
|
});
|
||||||
|
|
||||||
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
|
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
|||||||
priority: "Priority",
|
priority: "Priority",
|
||||||
assignee: "Assignee",
|
assignee: "Assignee",
|
||||||
project: "Project",
|
project: "Project",
|
||||||
|
recurring: "Recurring",
|
||||||
targetDate: "Target date",
|
targetDate: "Target date",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
|||||||
agents: Set<string>;
|
agents: Set<string>;
|
||||||
projects: Set<string>;
|
projects: Set<string>;
|
||||||
tasks: Set<string>;
|
tasks: Set<string>;
|
||||||
|
routines: Set<string>;
|
||||||
} {
|
} {
|
||||||
const agents = new Set<string>();
|
const agents = new Set<string>();
|
||||||
const projects = new Set<string>();
|
const projects = new Set<string>();
|
||||||
@@ -62,7 +63,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
|||||||
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
||||||
if (taskMatch) tasks.add(taskMatch[1]);
|
if (taskMatch) tasks.add(taskMatch[1]);
|
||||||
}
|
}
|
||||||
return { agents, projects, tasks };
|
return { agents, projects, tasks, routines: new Set(tasks) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +78,7 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
|||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
// Sections whose entries are slug-keyed and should be filtered
|
// Sections whose entries are slug-keyed and should be filtered
|
||||||
const filterableSections = new Set(["agents", "projects", "tasks"]);
|
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
|
||||||
|
|
||||||
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
||||||
let currentEntry: string | null = null; // slug under that section
|
let currentEntry: string | null = null; // slug under that section
|
||||||
|
|||||||
Reference in New Issue
Block a user