diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index a3376a89b..99de314d0 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -28,7 +28,7 @@ These define the contract between server, CLI, and UI. | 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/types/index.ts` | Re-exports portability types. | | `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 | |---|---| -| `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/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` | | `TASK.md` frontmatter & body | `company-portability.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) | | ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) | | Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) | diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index fd2c4842d..21406e18d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -860,11 +860,14 @@ Export/import behavior in V1: - 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 -- 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 - import supports target modes: - create a new 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 preview (dry-run) before apply - GitHub imports warn on unpinned refs instead of blocking diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index 17fa8ef3b..5f1327dbf 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -253,17 +253,7 @@ owner: cto name: Monday Review assignee: ceo project: q2-launch -schedule: - timezone: America/Chicago - startsAt: 2026-03-16T09:00:00-05:00 - recurrence: - frequency: weekly - interval: 1 - weekdays: - - monday - time: - hour: 9 - minute: 0 +recurring: true ``` ### Semantics @@ -271,58 +261,30 @@ schedule: - body content is the canonical markdown task description - `assignee` should reference an agent slug inside the package - `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 -### 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 -- 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: +Example Paperclip extension: ```yaml -schedule: - timezone: America/Chicago - startsAt: 2026-03-14T09:00:00-05:00 - recurrence: - frequency: hourly | daily | weekly | monthly | yearly - interval: 1 - 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 +routines: + monday-review: + triggers: + - kind: schedule + cronExpression: "0 9 * * 1" + timezone: America/Chicago ``` -Rules: - -- `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 +- 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 ## 11. SKILL.md Compatibility @@ -449,7 +411,7 @@ Suggested import UI behavior: - selecting an agent auto-selects required docs and referenced skills - selecting a team auto-selects its subtree - 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 ## 15. Vendor Extensions @@ -502,6 +464,12 @@ agents: kind: plain requirement: optional default: claude +routines: + monday-review: + triggers: + - kind: schedule + cronExpression: "0 9 * * 1" + timezone: America/Chicago ``` Additional rules for Paperclip exporters: @@ -520,7 +488,7 @@ A compliant exporter should: - omit machine-local ids and timestamps - omit secret values - 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 - default to the vendor-neutral base package - 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 - `AGENTS.md` -> agent identity and instructions - `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 - `sources[]` -> provenance and pinned upstream refs - 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: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6a105d79d..85494986e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -256,6 +256,9 @@ export type { CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 260888310..26400c571 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -44,18 +44,52 @@ export interface CompanyPortabilityProjectManifestEntry { color: string | null; status: string | null; executionWorkspacePolicy: Record | null; + workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[]; metadata: Record | 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 | 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 { slug: string; identifier: string | null; title: string; path: string; projectSlug: string | null; + projectWorkspaceKey: string | null; assigneeAgentSlug: string | null; description: string | null; - recurrence: Record | null; + recurring: boolean; + routine: CompanyPortabilityIssueRoutineManifestEntry | null; + legacyRecurrence: Record | null; status: string | null; priority: string | null; labelIds: string[]; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e6ae52026..028ae7873 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -147,6 +147,9 @@ export type { CompanyPortabilityAgentManifestEntry, CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index cae50e89a..72359bb89 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -85,18 +85,50 @@ export const portabilityProjectManifestEntrySchema = z.object({ color: z.string().nullable(), status: z.string().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(), }); +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({ slug: z.string().min(1), identifier: z.string().min(1).nullable(), title: z.string().min(1), path: z.string().min(1), projectSlug: z.string().min(1).nullable(), + projectWorkspaceKey: z.string().min(1).nullable(), assigneeAgentSlug: z.string().min(1).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(), priority: z.string().nullable(), labelIds: z.array(z.string().min(1)).default([]), diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 0e5933697..5cf46fa20 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -25,6 +25,8 @@ const projectSvc = { list: vi.fn(), create: vi.fn(), update: vi.fn(), + createWorkspace: vi.fn(), + listWorkspaces: vi.fn(), }; const issueSvc = { @@ -34,6 +36,13 @@ const issueSvc = { create: vi.fn(), }; +const routineSvc = { + list: vi.fn(), + getDetail: vi.fn(), + create: vi.fn(), + createTrigger: vi.fn(), +}; + const companySkillSvc = { list: vi.fn(), listFull: vi.fn(), @@ -71,6 +80,10 @@ vi.mock("../services/issues.js", () => ({ issueService: () => issueSvc, })); +vi.mock("../services/routines.js", () => ({ + routineService: () => routineSvc, +})); + vi.mock("../services/company-skills.js", () => ({ companySkillService: () => companySkillSvc, })); @@ -184,9 +197,62 @@ describe("company portability", () => { }, ]); projectSvc.list.mockResolvedValue([]); + projectSvc.createWorkspace.mockResolvedValue(null); + projectSvc.listWorkspaces.mockResolvedValue([]); issueSvc.list.mockResolvedValue([]); issueSvc.getById.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) => ({ + 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) => ({ + 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 = [ { id: "skill-1", @@ -599,6 +665,200 @@ describe("company portability", () => { 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) => ({ + id: projectId, + name: "Launch", + urlKey: "launch", + ...data, + })); + projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record) => ({ + 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 | 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 () => { 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 () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 704085ddb..19690dc39 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -20,6 +20,9 @@ import type { CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewResult, CompanyPortabilityProjectManifestEntry, + CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityIssueRoutineManifestEntry, + CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueManifestEntry, CompanyPortabilitySkillManifestEntry, CompanySkill, @@ -28,6 +31,11 @@ import { ISSUE_PRIORITIES, ISSUE_STATUSES, PROJECT_STATUSES, + ROUTINE_CATCH_UP_POLICIES, + ROUTINE_CONCURRENCY_POLICIES, + ROUTINE_STATUSES, + ROUTINE_TRIGGER_KINDS, + ROUTINE_TRIGGER_SIGNING_MODES, deriveProjectUrlKey, normalizeAgentUrlKey, } from "@paperclipai/shared"; @@ -45,8 +53,10 @@ import { generateReadme } from "./company-export-readme.js"; import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js"; import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; +import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; +import { routineService } from "./routines.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { @@ -395,6 +405,7 @@ type PaperclipExtensionDoc = { agents?: Record> | null; projects?: Record> | null; tasks?: Record> | null; + routines?: Record> | null; }; type ProjectLike = { @@ -406,6 +417,20 @@ type ProjectLike = { color: string | null; status: string; executionWorkspacePolicy: Record | null; + workspaces?: Array<{ + id: string; + name: string; + sourceType: string; + cwd: string | null; + repoUrl: string | null; + repoRef: string | null; + defaultRef: string | null; + visibility: string; + setupCommand: string | null; + cleanupCommand: string | null; + metadata?: Record | null; + isPrimary: boolean; + }>; metadata?: Record | null; }; @@ -415,6 +440,7 @@ type IssueLike = { title: string; description: string | null; projectId: string | null; + projectWorkspaceId: string | null; assigneeAgentId: string | null; status: string; priority: string; @@ -424,6 +450,8 @@ type IssueLike = { assigneeAdapterOverrides: Record | null; }; +type RoutineLike = NonNullable["getDetail"]>>>; + type ImportPlanInternal = { preview: CompanyPortabilityPreviewResult; source: ResolvedSource; @@ -515,6 +543,506 @@ function asString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function asInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null { + if (!isPlainRecord(value)) return null; + const kind = asString(value.kind); + if (!kind) return null; + return { + kind, + label: asString(value.label), + enabled: asBoolean(value.enabled) ?? true, + cronExpression: asString(value.cronExpression), + timezone: asString(value.timezone), + signingMode: asString(value.signingMode), + replayWindowSec: asInteger(value.replayWindowSec), + }; +} + +function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null { + if (!isPlainRecord(value)) return null; + const triggers = Array.isArray(value.triggers) + ? value.triggers + .map((entry) => normalizeRoutineTriggerExtension(entry)) + .filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null) + : []; + const routine = { + concurrencyPolicy: asString(value.concurrencyPolicy), + catchUpPolicy: asString(value.catchUpPolicy), + triggers, + }; + return stripEmptyValues(routine) ? routine : null; +} + +function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPortabilityIssueRoutineManifestEntry { + return { + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + triggers: routine.triggers.map((trigger) => ({ + kind: trigger.kind, + label: trigger.label ?? null, + enabled: Boolean(trigger.enabled), + cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : null, + timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : null, + signingMode: trigger.kind === "webhook" ? trigger.signingMode ?? null : null, + replayWindowSec: trigger.kind === "webhook" ? trigger.replayWindowSec ?? null : null, + })), + }; +} + +function containsAbsolutePathFragment(value: string) { + return /(^|\s)(\/[^/\s]|[A-Za-z]:[\\/])/.test(value); +} + +function containsSystemDependentPathValue(value: unknown): boolean { + if (typeof value === "string") { + return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || containsAbsolutePathFragment(value); + } + if (Array.isArray(value)) { + return value.some((entry) => containsSystemDependentPathValue(entry)); + } + if (isPlainRecord(value)) { + return Object.values(value).some((entry) => containsSystemDependentPathValue(entry)); + } + return false; +} + +function clonePortableRecord(value: unknown) { + if (!isPlainRecord(value)) return null; + return structuredClone(value) as Record; +} + +function normalizePortableProjectWorkspaceExtension( + workspaceKey: string, + value: unknown, +): CompanyPortabilityProjectWorkspaceManifestEntry | null { + if (!isPlainRecord(value)) return null; + const normalizedKey = normalizeAgentUrlKey(workspaceKey) ?? workspaceKey.trim(); + if (!normalizedKey) return null; + return { + key: normalizedKey, + name: asString(value.name) ?? normalizedKey, + sourceType: asString(value.sourceType), + repoUrl: asString(value.repoUrl), + repoRef: asString(value.repoRef), + defaultRef: asString(value.defaultRef), + visibility: asString(value.visibility), + setupCommand: asString(value.setupCommand), + cleanupCommand: asString(value.cleanupCommand), + metadata: isPlainRecord(value.metadata) ? value.metadata : null, + isPrimary: asBoolean(value.isPrimary) ?? false, + }; +} + +function derivePortableProjectWorkspaceKey( + workspace: NonNullable[number], + usedKeys: Set, +) { + const baseKey = + normalizeAgentUrlKey(workspace.name) + ?? normalizeAgentUrlKey(asString(workspace.repoUrl)?.split("/").pop()?.replace(/\.git$/i, "") ?? "") + ?? "workspace"; + return uniqueSlug(baseKey, usedKeys); +} + +function exportPortableProjectExecutionWorkspacePolicy( + projectSlug: string, + policy: unknown, + workspaceKeyById: Map, + warnings: string[], +) { + const next = clonePortableRecord(policy); + if (!next) return null; + const defaultWorkspaceId = asString(next.defaultProjectWorkspaceId); + if (defaultWorkspaceId) { + const defaultWorkspaceKey = workspaceKeyById.get(defaultWorkspaceId); + if (defaultWorkspaceKey) { + next.defaultProjectWorkspaceKey = defaultWorkspaceKey; + } else { + warnings.push(`Project ${projectSlug} default workspace ${defaultWorkspaceId} was omitted from export because that workspace is not portable.`); + } + delete next.defaultProjectWorkspaceId; + } + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function importPortableProjectExecutionWorkspacePolicy( + projectSlug: string, + policy: Record | null | undefined, + workspaceIdByKey: Map, + warnings: string[], +) { + const next = clonePortableRecord(policy); + if (!next) return null; + const defaultWorkspaceKey = asString(next.defaultProjectWorkspaceKey); + if (defaultWorkspaceKey) { + const defaultWorkspaceId = workspaceIdByKey.get(defaultWorkspaceKey); + if (defaultWorkspaceId) { + next.defaultProjectWorkspaceId = defaultWorkspaceId; + } else { + warnings.push(`Project ${projectSlug} references missing workspace key ${defaultWorkspaceKey}; imported execution workspace policy without a default workspace.`); + } + } + delete next.defaultProjectWorkspaceKey; + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function stripPortableProjectExecutionWorkspaceRefs(policy: Record | null | undefined) { + const next = clonePortableRecord(policy); + if (!next) return null; + delete next.defaultProjectWorkspaceId; + delete next.defaultProjectWorkspaceKey; + const cleaned = stripEmptyValues(next); + return isPlainRecord(cleaned) ? cleaned : null; +} + +function buildPortableProjectWorkspaces( + projectSlug: string, + workspaces: ProjectLike["workspaces"] | undefined, + warnings: string[], +) { + const exportedWorkspaces: Record> = {}; + const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = []; + const workspaceKeyById = new Map(); + const usedKeys = new Set(); + + for (const workspace of workspaces ?? []) { + const repoUrl = asString(workspace.repoUrl); + if (!repoUrl) { + warnings.push(`Project ${projectSlug} workspace ${workspace.name} was omitted from export because it does not have a portable repoUrl.`); + continue; + } + + const workspaceKey = derivePortableProjectWorkspaceKey(workspace, usedKeys); + workspaceKeyById.set(workspace.id, workspaceKey); + + let setupCommand = asString(workspace.setupCommand); + if (setupCommand && containsAbsolutePathFragment(setupCommand)) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} setupCommand was omitted from export because it is system-dependent.`); + setupCommand = null; + } + + let cleanupCommand = asString(workspace.cleanupCommand); + if (cleanupCommand && containsAbsolutePathFragment(cleanupCommand)) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} cleanupCommand was omitted from export because it is system-dependent.`); + cleanupCommand = null; + } + + const metadata = isPlainRecord(workspace.metadata) && !containsSystemDependentPathValue(workspace.metadata) + ? workspace.metadata + : null; + if (isPlainRecord(workspace.metadata) && metadata == null) { + warnings.push(`Project ${projectSlug} workspace ${workspaceKey} metadata was omitted from export because it contains system-dependent paths.`); + } + + const portableWorkspace = stripEmptyValues({ + name: workspace.name, + sourceType: workspace.sourceType, + repoUrl, + repoRef: asString(workspace.repoRef), + defaultRef: asString(workspace.defaultRef), + visibility: asString(workspace.visibility), + setupCommand, + cleanupCommand, + metadata, + isPrimary: workspace.isPrimary ? true : undefined, + }); + if (!isPlainRecord(portableWorkspace)) continue; + + exportedWorkspaces[workspaceKey] = portableWorkspace; + manifestWorkspaces.push({ + key: workspaceKey, + name: workspace.name, + sourceType: asString(workspace.sourceType), + repoUrl, + repoRef: asString(workspace.repoRef), + defaultRef: asString(workspace.defaultRef), + visibility: asString(workspace.visibility), + setupCommand, + cleanupCommand, + metadata, + isPrimary: workspace.isPrimary, + }); + } + + return { + extension: Object.keys(exportedWorkspaces).length > 0 ? exportedWorkspaces : undefined, + manifest: manifestWorkspaces, + workspaceKeyById, + }; +} + +const WEEKDAY_TO_CRON: Record = { + sunday: "0", + monday: "1", + tuesday: "2", + wednesday: "3", + thursday: "4", + friday: "5", + saturday: "6", +}; + +function readZonedDateParts(startsAt: string, timeZone: string) { + try { + const date = new Date(startsAt); + if (Number.isNaN(date.getTime())) return null; + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + weekday: "long", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); + const parts = Object.fromEntries( + formatter + .formatToParts(date) + .filter((entry) => entry.type !== "literal") + .map((entry) => [entry.type, entry.value]), + ) as Record; + const weekday = WEEKDAY_TO_CRON[parts.weekday?.toLowerCase() ?? ""]; + const month = Number(parts.month); + const day = Number(parts.day); + const hour = Number(parts.hour); + const minute = Number(parts.minute); + if (!weekday || !Number.isFinite(month) || !Number.isFinite(day) || !Number.isFinite(hour) || !Number.isFinite(minute)) { + return null; + } + return { weekday, month, day, hour, minute }; + } catch { + return null; + } +} + +function normalizeCronList(values: string[]) { + return Array.from(new Set(values)).sort((left, right) => Number(left) - Number(right)).join(","); +} + +function buildLegacyRoutineTriggerFromRecurrence( + issue: Pick, + scheduleValue: unknown, +) { + const warnings: string[] = []; + const errors: string[] = []; + if (!issue.legacyRecurrence || !isPlainRecord(issue.legacyRecurrence)) { + return { trigger: null, warnings, errors }; + } + + const schedule = isPlainRecord(scheduleValue) ? scheduleValue : null; + const frequency = asString(issue.legacyRecurrence.frequency); + const interval = asInteger(issue.legacyRecurrence.interval) ?? 1; + if (!frequency) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence without frequency; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + if (interval < 1) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid interval; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + const timezone = asString(schedule?.timezone) ?? "UTC"; + const startsAt = asString(schedule?.startsAt); + const zonedStartsAt = startsAt ? readZonedDateParts(startsAt, timezone) : null; + if (startsAt && !zonedStartsAt) { + errors.push(`Recurring task ${issue.slug} has an invalid legacy startsAt/timezone combination; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + const time = isPlainRecord(issue.legacyRecurrence.time) ? issue.legacyRecurrence.time : null; + const hour = asInteger(time?.hour) ?? zonedStartsAt?.hour ?? 0; + const minute = asInteger(time?.minute) ?? zonedStartsAt?.minute ?? 0; + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + errors.push(`Recurring task ${issue.slug} uses legacy recurrence with an invalid time; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + if (issue.legacyRecurrence.until != null || issue.legacyRecurrence.count != null) { + warnings.push(`Recurring task ${issue.slug} uses legacy recurrence end bounds; Paperclip will import the routine trigger without those limits.`); + } + + let cronExpression: string | null = null; + + if (frequency === "hourly") { + const hourField = interval === 1 + ? "*" + : zonedStartsAt + ? `${zonedStartsAt.hour}-23/${interval}` + : `*/${interval}`; + cronExpression = `${minute} ${hourField} * * *`; + } else if (frequency === "daily") { + if (Array.isArray(issue.legacyRecurrence.weekdays) || Array.isArray(issue.legacyRecurrence.monthDays) || Array.isArray(issue.legacyRecurrence.months)) { + errors.push(`Recurring task ${issue.slug} uses unsupported legacy daily recurrence constraints; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const dayField = interval === 1 ? "*" : `*/${interval}`; + cronExpression = `${minute} ${hour} ${dayField} * *`; + } else if (frequency === "weekly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const weekdays = Array.isArray(issue.legacyRecurrence.weekdays) + ? issue.legacyRecurrence.weekdays + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)) + : []; + const cronWeekdays = weekdays + .map((entry) => WEEKDAY_TO_CRON[entry.toLowerCase()]) + .filter((entry): entry is string => Boolean(entry)); + if (cronWeekdays.length === 0 && zonedStartsAt?.weekday) { + cronWeekdays.push(zonedStartsAt.weekday); + } + if (cronWeekdays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy weekly recurrence without weekdays; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + cronExpression = `${minute} ${hour} * * ${normalizeCronList(cronWeekdays)}`; + } else if (frequency === "monthly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + if (Array.isArray(issue.legacyRecurrence.ordinalWeekdays) && issue.legacyRecurrence.ordinalWeekdays.length > 0) { + errors.push(`Recurring task ${issue.slug} uses legacy ordinal monthly recurrence; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const monthDays = Array.isArray(issue.legacyRecurrence.monthDays) + ? issue.legacyRecurrence.monthDays + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31) + : []; + if (monthDays.length === 0 && zonedStartsAt?.day) { + monthDays.push(zonedStartsAt.day); + } + if (monthDays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy monthly recurrence without monthDays; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const months = Array.isArray(issue.legacyRecurrence.months) + ? issue.legacyRecurrence.months + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12) + : []; + const monthField = months.length > 0 ? normalizeCronList(months.map(String)) : "*"; + cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${monthField} *`; + } else if (frequency === "yearly") { + if (interval !== 1) { + errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence with interval > 1; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + const months = Array.isArray(issue.legacyRecurrence.months) + ? issue.legacyRecurrence.months + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 12) + : []; + if (months.length === 0 && zonedStartsAt?.month) { + months.push(zonedStartsAt.month); + } + const monthDays = Array.isArray(issue.legacyRecurrence.monthDays) + ? issue.legacyRecurrence.monthDays + .map((entry) => asInteger(entry)) + .filter((entry): entry is number => entry != null && entry >= 1 && entry <= 31) + : []; + if (monthDays.length === 0 && zonedStartsAt?.day) { + monthDays.push(zonedStartsAt.day); + } + if (months.length === 0 || monthDays.length === 0) { + errors.push(`Recurring task ${issue.slug} uses legacy yearly recurrence without month/monthDay anchors; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + cronExpression = `${minute} ${hour} ${normalizeCronList(monthDays.map(String))} ${normalizeCronList(months.map(String))} *`; + } else { + errors.push(`Recurring task ${issue.slug} uses unsupported legacy recurrence frequency "${frequency}"; add .paperclip.yaml routines.${issue.slug}.triggers.`); + return { trigger: null, warnings, errors }; + } + + return { + trigger: { + kind: "schedule", + label: "Migrated legacy recurrence", + enabled: true, + cronExpression, + timezone, + signingMode: null, + replayWindowSec: null, + } satisfies CompanyPortabilityIssueRoutineTriggerManifestEntry, + warnings, + errors, + }; +} + +function resolvePortableRoutineDefinition( + issue: Pick, + scheduleValue: unknown, +) { + const warnings: string[] = []; + const errors: string[] = []; + if (!issue.recurring) { + return { routine: null, warnings, errors }; + } + + const routine = issue.routine + ? { + concurrencyPolicy: issue.routine.concurrencyPolicy, + catchUpPolicy: issue.routine.catchUpPolicy, + triggers: [...issue.routine.triggers], + } + : { + concurrencyPolicy: null, + catchUpPolicy: null, + triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[], + }; + + if (routine.concurrencyPolicy && !ROUTINE_CONCURRENCY_POLICIES.includes(routine.concurrencyPolicy as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported routine concurrencyPolicy "${routine.concurrencyPolicy}".`); + } + if (routine.catchUpPolicy && !ROUTINE_CATCH_UP_POLICIES.includes(routine.catchUpPolicy as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported routine catchUpPolicy "${routine.catchUpPolicy}".`); + } + + for (const trigger of routine.triggers) { + if (!ROUTINE_TRIGGER_KINDS.includes(trigger.kind as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported trigger kind "${trigger.kind}".`); + continue; + } + if (trigger.kind === "schedule") { + if (!trigger.cronExpression || !trigger.timezone) { + errors.push(`Recurring task ${issue.slug} has a schedule trigger missing cronExpression/timezone.`); + continue; + } + const cronError = validateCron(trigger.cronExpression); + if (cronError) { + errors.push(`Recurring task ${issue.slug} has an invalid schedule trigger: ${cronError}`); + } + continue; + } + if (trigger.kind === "webhook" && trigger.signingMode && !ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any)) { + errors.push(`Recurring task ${issue.slug} uses unsupported webhook signingMode "${trigger.signingMode}".`); + } + } + + if (routine.triggers.length === 0 && issue.legacyRecurrence) { + const migrated = buildLegacyRoutineTriggerFromRecurrence(issue, scheduleValue); + warnings.push(...migrated.warnings); + errors.push(...migrated.errors); + if (migrated.trigger) { + routine.triggers.push(migrated.trigger); + } + } + + return { routine, warnings, errors }; +} + function toSafeSlug(input: string, fallback: string) { return normalizeAgentUrlKey(input) ?? fallback; } @@ -701,14 +1229,14 @@ function collectSelectedExportSlugs(selectedFiles: Set) { const taskMatch = filePath.match(/^tasks\/([^/]+)\//); if (taskMatch) tasks.add(taskMatch[1]!); } - return { agents, projects, tasks }; + return { agents, projects, tasks, routines: new Set(tasks) }; } function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { const selected = collectSelectedExportSlugs(selectedFiles); const lines = yaml.split("\n"); const out: string[] = []; - const filterableSections = new Set(["agents", "projects", "tasks"]); + const filterableSections = new Set(["agents", "projects", "tasks", "routines"]); let currentSection: string | null = null; let currentEntry: string | null = null; @@ -1604,6 +2132,7 @@ function buildManifestFromPackageFiles( const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; + const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {}; const companyName = asString(companyFrontmatter.name) ?? opts?.sourceLabel?.companyName @@ -1644,7 +2173,7 @@ function buildManifestFromPackageFiles( const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort(); const manifest: CompanyPortabilityManifest = { - schemaVersion: 3, + schemaVersion: 4, generatedAt: new Date().toISOString(), source: opts?.sourceLabel ?? null, includes: { @@ -1824,6 +2353,10 @@ function buildManifestFromPackageFiles( ); const slug = asString(frontmatter.slug) ?? fallbackSlug; const extension = isPlainRecord(paperclipProjects[slug]) ? paperclipProjects[slug] : {}; + const workspaceExtensions = isPlainRecord(extension.workspaces) ? extension.workspaces : {}; + const workspaces = Object.entries(workspaceExtensions) + .map(([workspaceKey, entry]) => normalizePortableProjectWorkspaceExtension(workspaceKey, entry)) + .filter((entry): entry is CompanyPortabilityProjectWorkspaceManifestEntry => entry !== null); manifest.projects.push({ slug, name: asString(frontmatter.name) ?? slug, @@ -1837,6 +2370,7 @@ function buildManifestFromPackageFiles( executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy) ? extension.executionWorkspacePolicy : null, + workspaces, metadata: isPlainRecord(extension.metadata) ? extension.metadata : null, }); if (frontmatter.kind && frontmatter.kind !== "project") { @@ -1855,23 +2389,32 @@ function buildManifestFromPackageFiles( const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(taskPath))) ?? "task"; const slug = asString(frontmatter.slug) ?? fallbackSlug; const extension = isPlainRecord(paperclipTasks[slug]) ? paperclipTasks[slug] : {}; + const routineExtension = normalizeRoutineExtension(paperclipRoutines[slug]); + const routineExtensionRaw = isPlainRecord(paperclipRoutines[slug]) ? paperclipRoutines[slug] : {}; const schedule = isPlainRecord(frontmatter.schedule) ? frontmatter.schedule : null; - const recurrence = schedule && isPlainRecord(schedule.recurrence) + const legacyRecurrence = schedule && isPlainRecord(schedule.recurrence) ? schedule.recurrence : isPlainRecord(extension.recurrence) ? extension.recurrence : null; + const recurring = + asBoolean(frontmatter.recurring) === true + || routineExtension !== null + || legacyRecurrence !== null; manifest.issues.push({ slug, identifier: asString(extension.identifier), title: asString(frontmatter.name) ?? asString(frontmatter.title) ?? slug, path: taskPath, projectSlug: asString(frontmatter.project), + projectWorkspaceKey: asString(extension.projectWorkspaceKey), assigneeAgentSlug: asString(frontmatter.assignee), description: taskDoc.body || asString(frontmatter.description), - recurrence, - status: asString(extension.status), - priority: asString(extension.priority), + recurring, + routine: routineExtension, + legacyRecurrence, + status: asString(extension.status) ?? asString(routineExtensionRaw.status), + priority: asString(extension.priority) ?? asString(routineExtensionRaw.priority), labelIds: Array.isArray(extension.labelIds) ? extension.labelIds.filter((entry): entry is string => typeof entry === "string") : [], @@ -2134,8 +2677,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projectsSvc = projectService(db); const issuesSvc = issueService(db); + const routinesSvc = routineService(db); const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : []; const allProjects = allProjectsRaw.filter((project) => !project.archivedAt); + const allRoutines = include.issues ? await routinesSvc.list(companyId) : []; const projectById = new Map(allProjects.map((project) => [project.id, project])); const projectByReference = new Map(); for (const project of allProjects) { @@ -2155,6 +2700,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } const selectedIssues = new Map>>(); + const selectedRoutines = new Map(); + const routineById = new Map(allRoutines.map((routine) => [routine.id, routine])); const resolveIssueBySelector = async (selector: string) => { const trimmed = selector.trim(); if (!trimmed) return null; @@ -2165,6 +2712,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const selector of input.issues ?? []) { const issue = await resolveIssueBySelector(selector); if (!issue || issue.companyId !== companyId) { + const routine = routineById.get(selector.trim()); + if (routine) { + selectedRoutines.set(routine.id, routine); + if (routine.projectId) { + const parentProject = projectById.get(routine.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + continue; + } warnings.push(`Issue selector "${selector}" was not found and was skipped.`); continue; } @@ -2186,6 +2742,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const issue of projectIssues) { selectedIssues.set(issue.id, issue); } + for (const routine of allRoutines.filter((entry) => entry.projectId === match.id)) { + selectedRoutines.set(routine.id, routine); + } } if (include.projects && selectedProjects.size === 0) { @@ -2203,6 +2762,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (parentProject) selectedProjects.set(parentProject.id, parentProject); } } + if (selectedRoutines.size === 0) { + for (const routine of allRoutines) { + selectedRoutines.set(routine.id, routine); + if (routine.projectId) { + const parentProject = projectById.get(routine.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + } + } } const selectedProjectRows = Array.from(selectedProjects.values()) @@ -2210,15 +2778,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const selectedIssueRows = Array.from(selectedIssues.values()) .filter((issue): issue is NonNullable => issue != null) .sort((left, right) => (left.identifier ?? left.title).localeCompare(right.identifier ?? right.title)); + const selectedRoutineSummaries = Array.from(selectedRoutines.values()) + .sort((left, right) => left.title.localeCompare(right.title)); + const selectedRoutineRows = ( + await Promise.all(selectedRoutineSummaries.map((routine) => routinesSvc.getDetail(routine.id))) + ).filter((routine): routine is RoutineLike => routine !== null); const taskSlugByIssueId = new Map(); + const taskSlugByRoutineId = new Map(); const usedTaskSlugs = new Set(); for (const issue of selectedIssueRows) { const baseSlug = normalizeAgentUrlKey(issue.identifier ?? issue.title) ?? "task"; taskSlugByIssueId.set(issue.id, uniqueSlug(baseSlug, usedTaskSlugs)); } + for (const routine of selectedRoutineRows) { + const baseSlug = normalizeAgentUrlKey(routine.title) ?? "task"; + taskSlugByRoutineId.set(routine.id, uniqueSlug(baseSlug, usedTaskSlugs)); + } const projectSlugById = new Map(); + const projectWorkspaceKeyByProjectId = new Map>(); const usedProjectSlugs = new Set(); for (const project of selectedProjectRows) { const baseSlug = deriveProjectUrlKey(project.name, project.name); @@ -2259,6 +2838,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + const paperclipRoutinesOut: Record> = {}; const skillByReference = new Map(); for (const skill of companySkillRows) { @@ -2391,6 +2971,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const project of selectedProjectRows) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; + const portableWorkspaces = buildPortableProjectWorkspaces(slug, project.workspaces, warnings); + projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById); files[projectPath] = buildMarkdown( { name: project.name, @@ -2404,7 +2986,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { targetDate: project.targetDate ?? null, color: project.color ?? null, status: project.status, - executionWorkspacePolicy: project.executionWorkspacePolicy ?? undefined, + executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy( + slug, + project.executionWorkspacePolicy, + portableWorkspaces.workspaceKeyById, + warnings, + ) ?? undefined, + workspaces: portableWorkspaces.extension, }); paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {}; } @@ -2415,6 +3003,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // All tasks go in top-level tasks/ folder, never nested under projects/ const taskPath = `tasks/${taskSlug}/TASK.md`; const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null; + const projectWorkspaceKey = issue.projectId && issue.projectWorkspaceId + ? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null + : null; + if (issue.projectWorkspaceId && !projectWorkspaceKey) { + warnings.push(`Task ${taskSlug} workspace reference ${issue.projectWorkspaceId} was omitted from export because that workspace is not portable.`); + } files[taskPath] = buildMarkdown( { name: issue.title, @@ -2429,12 +3023,47 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { priority: issue.priority, labelIds: issue.labelIds ?? undefined, billingCode: issue.billingCode ?? null, + projectWorkspaceKey: projectWorkspaceKey ?? undefined, executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined, assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined, }); paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {}; } + for (const routine of selectedRoutineRows) { + const taskSlug = taskSlugByRoutineId.get(routine.id)!; + const projectSlug = projectSlugById.get(routine.projectId) ?? null; + const taskPath = `tasks/${taskSlug}/TASK.md`; + const assigneeSlug = idToSlug.get(routine.assigneeAgentId) ?? null; + files[taskPath] = buildMarkdown( + { + name: routine.title, + project: projectSlug, + assignee: assigneeSlug, + recurring: true, + }, + routine.description ?? "", + ); + const extension = stripEmptyValues({ + status: routine.status !== "active" ? routine.status : undefined, + priority: routine.priority !== "medium" ? routine.priority : undefined, + concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined, + catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined, + triggers: routine.triggers.map((trigger) => stripEmptyValues({ + kind: trigger.kind, + label: trigger.label ?? null, + enabled: trigger.enabled ? undefined : false, + cronExpression: trigger.kind === "schedule" ? trigger.cronExpression ?? null : undefined, + timezone: trigger.kind === "schedule" ? trigger.timezone ?? null : undefined, + signingMode: trigger.kind === "webhook" && trigger.signingMode !== "bearer" ? trigger.signingMode ?? null : undefined, + replayWindowSec: trigger.kind === "webhook" && trigger.replayWindowSec !== 300 + ? trigger.replayWindowSec ?? null + : undefined, + })), + }); + paperclipRoutinesOut[taskSlug] = isPlainRecord(extension) ? extension : {}; + } + const paperclipExtensionPath = ".paperclip.yaml"; const paperclipAgents = Object.fromEntries( Object.entries(paperclipAgentsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), @@ -2445,6 +3074,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipTasks = Object.fromEntries( Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), ); + const paperclipRoutines = Object.fromEntries( + Object.entries(paperclipRoutinesOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); files[paperclipExtensionPath] = buildYamlFile( { schema: "paperclip/v1", @@ -2456,6 +3088,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, + routines: Object.keys(paperclipRoutines).length > 0 ? paperclipRoutines : undefined, }, { preserveEmptyStrings: true }, ); @@ -2644,6 +3277,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (include.issues) { + const projectBySlug = new Map(manifest.projects.map((project) => [project.slug, project])); for (const issue of manifest.issues) { const markdown = readPortableTextFile(source.files, ensureMarkdownPath(issue.path)); if (typeof markdown !== "string") { @@ -2654,8 +3288,24 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "task") { warnings.push(`Task markdown ${issue.path} does not declare kind: task in frontmatter.`); } - if (issue.recurrence) { - warnings.push(`Task ${issue.slug} has recurrence metadata; Paperclip will import it as a one-time issue for now.`); + if (issue.projectWorkspaceKey) { + const project = issue.projectSlug ? projectBySlug.get(issue.projectSlug) ?? null : null; + if (!project) { + warnings.push(`Task ${issue.slug} references workspace key ${issue.projectWorkspaceKey}, but its project is not present in the package.`); + } else if (!project.workspaces.some((workspace) => workspace.key === issue.projectWorkspaceKey)) { + warnings.push(`Task ${issue.slug} references missing project workspace key ${issue.projectWorkspaceKey}.`); + } + } + if (issue.recurring) { + if (!issue.projectSlug) { + errors.push(`Recurring task ${issue.slug} must declare a project to import as a routine.`); + } + if (!issue.assigneeAgentSlug) { + errors.push(`Recurring task ${issue.slug} must declare an assignee to import as a routine.`); + } + const resolvedRoutine = resolvePortableRoutineDefinition(issue, parsed.frontmatter.schedule); + warnings.push(...resolvedRoutine.warnings); + errors.push(...resolvedRoutine.errors); } } } @@ -2847,7 +3497,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { slug: manifestIssue.slug, action: "create", plannedTitle: manifestIssue.title, - reason: manifestIssue.recurrence ? "Recurrence will not be activated on import." : null, + reason: manifestIssue.recurring ? "Recurring task will be imported as a routine." : null, }); } } @@ -3024,6 +3674,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); } const importedSlugToProjectId = new Map(); + const importedProjectWorkspaceIdByProjectSlug = new Map>(); const existingProjectSlugToId = new Map(); const existingProjects = await projects.list(targetCompany.id); for (const existing of existingProjects) { @@ -3202,6 +3853,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ?? existingSlugToAgentId.get(manifestProject.leadAgentSlug) ?? null : null; + const projectWorkspaceIdByKey = new Map(); const projectPatch = { name: planProject.plannedName, description: manifestProject.description, @@ -3211,27 +3863,65 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) ? manifestProject.status as typeof PROJECT_STATUSES[number] : "backlog", - executionWorkspacePolicy: manifestProject.executionWorkspacePolicy, + executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy), }; + let projectId: string | null = null; if (planProject.action === "update" && planProject.existingProjectId) { const updated = await projects.update(planProject.existingProjectId, projectPatch); if (!updated) { warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`); continue; } + projectId = updated.id; importedSlugToProjectId.set(planProject.slug, updated.id); existingProjectSlugToId.set(updated.urlKey, updated.id); - continue; + } else { + const created = await projects.create(targetCompany.id, projectPatch); + projectId = created.id; + importedSlugToProjectId.set(planProject.slug, created.id); + existingProjectSlugToId.set(created.urlKey, created.id); } - const created = await projects.create(targetCompany.id, projectPatch); - importedSlugToProjectId.set(planProject.slug, created.id); - existingProjectSlugToId.set(created.urlKey, created.id); + if (!projectId) continue; + + for (const workspace of manifestProject.workspaces) { + const createdWorkspace = await projects.createWorkspace(projectId, { + name: workspace.name, + sourceType: workspace.sourceType ?? undefined, + repoUrl: workspace.repoUrl ?? undefined, + repoRef: workspace.repoRef ?? undefined, + defaultRef: workspace.defaultRef ?? undefined, + visibility: workspace.visibility ?? undefined, + setupCommand: workspace.setupCommand ?? undefined, + cleanupCommand: workspace.cleanupCommand ?? undefined, + metadata: workspace.metadata ?? undefined, + isPrimary: workspace.isPrimary, + }); + if (!createdWorkspace) { + warnings.push(`Project ${planProject.slug} workspace ${workspace.key} could not be created during import.`); + continue; + } + projectWorkspaceIdByKey.set(workspace.key, createdWorkspace.id); + } + importedProjectWorkspaceIdByProjectSlug.set(planProject.slug, projectWorkspaceIdByKey); + + const hydratedProjectExecutionWorkspacePolicy = importPortableProjectExecutionWorkspacePolicy( + planProject.slug, + manifestProject.executionWorkspacePolicy, + projectWorkspaceIdByKey, + warnings, + ); + if (hydratedProjectExecutionWorkspacePolicy) { + await projects.update(projectId, { + executionWorkspacePolicy: hydratedProjectExecutionWorkspacePolicy, + }); + } } } if (include.issues) { + const routines = routineService(db); for (const manifestIssue of sourceManifest.issues) { const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; @@ -3246,8 +3936,95 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ?? existingProjectSlugToId.get(manifestIssue.projectSlug) ?? null : null; + const projectWorkspaceId = manifestIssue.projectSlug && manifestIssue.projectWorkspaceKey + ? importedProjectWorkspaceIdByProjectSlug.get(manifestIssue.projectSlug)?.get(manifestIssue.projectWorkspaceKey) ?? null + : null; + if (manifestIssue.projectWorkspaceKey && !projectWorkspaceId) { + warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); + } + if (manifestIssue.recurring) { + if (!projectId || !assigneeAgentId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); + } + const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); + if (resolvedRoutine.errors.length > 0) { + throw unprocessable(`Recurring task ${manifestIssue.slug} could not be imported as a routine: ${resolvedRoutine.errors.join("; ")}`); + } + warnings.push(...resolvedRoutine.warnings); + const routineDefinition = resolvedRoutine.routine ?? { + concurrencyPolicy: null, + catchUpPolicy: null, + triggers: [], + }; + const createdRoutine = await routines.create(targetCompany.id, { + projectId, + goalId: null, + parentIssueId: null, + title: manifestIssue.title, + description, + assigneeAgentId, + priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) + ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] + : "medium", + status: manifestIssue.status && ROUTINE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ROUTINE_STATUSES[number] + : "active", + concurrencyPolicy: + routineDefinition.concurrencyPolicy && ROUTINE_CONCURRENCY_POLICIES.includes(routineDefinition.concurrencyPolicy as any) + ? routineDefinition.concurrencyPolicy as typeof ROUTINE_CONCURRENCY_POLICIES[number] + : "coalesce_if_active", + catchUpPolicy: + routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any) + ? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number] + : "skip_missed", + }, { + agentId: null, + userId: actorUserId ?? null, + }); + for (const trigger of routineDefinition.triggers) { + if (trigger.kind === "schedule") { + await routines.createTrigger(createdRoutine.id, { + kind: "schedule", + label: trigger.label, + enabled: trigger.enabled, + cronExpression: trigger.cronExpression!, + timezone: trigger.timezone!, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + continue; + } + if (trigger.kind === "webhook") { + await routines.createTrigger(createdRoutine.id, { + kind: "webhook", + label: trigger.label, + enabled: trigger.enabled, + signingMode: + trigger.signingMode && ROUTINE_TRIGGER_SIGNING_MODES.includes(trigger.signingMode as any) + ? trigger.signingMode as typeof ROUTINE_TRIGGER_SIGNING_MODES[number] + : "bearer", + replayWindowSec: trigger.replayWindowSec ?? 300, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + continue; + } + await routines.createTrigger(createdRoutine.id, { + kind: "api", + label: trigger.label, + enabled: trigger.enabled, + }, { + agentId: null, + userId: actorUserId ?? null, + }); + } + continue; + } await issues.create(targetCompany.id, { projectId, + projectWorkspaceId, title: manifestIssue.title, description, assigneeAgentId, @@ -3262,9 +4039,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings, labelIds: [], }); - if (manifestIssue.recurrence) { - warnings.push(`Imported task ${manifestIssue.slug} as a one-time issue; recurrence metadata was not activated.`); - } } } diff --git a/ui/src/components/PackageFileTree.tsx b/ui/src/components/PackageFileTree.tsx index 5429328df..53e00195e 100644 --- a/ui/src/components/PackageFileTree.tsx +++ b/ui/src/components/PackageFileTree.tsx @@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record = { priority: "Priority", assignee: "Assignee", project: "Project", + recurring: "Recurring", targetDate: "Target date", }; diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 5ed8f640c..e0a3f4e7f 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -50,6 +50,7 @@ function checkedSlugs(checkedFiles: Set): { agents: Set; projects: Set; tasks: Set; + routines: Set; } { const agents = new Set(); const projects = new Set(); @@ -62,7 +63,7 @@ function checkedSlugs(checkedFiles: Set): { const taskMatch = p.match(/^tasks\/([^/]+)\//); 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 { const out: string[] = []; // 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 currentEntry: string | null = null; // slug under that section