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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions apps/api/migrations/150-task-board-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { type Kysely, sql } from "kysely";

/**
* Task board feature expansion — everything a team needs to run work on the
* Studio board instead of an external tracker (Jira parity for the
* ai-services-panel use case):
*
* - `organization_settings.task_board` (jsonb) — per-org board config:
* custom columns (each mapped to a canonical stage + optional per-column
* agent automation), and the sprint/release feature toggles. Null = the
* default simple board (the 5 built-in columns, sprints/releases off).
* - `task_board_items` grows `column_id` (custom-column placement — null means
* "derive from status"), `tags` (free-form labels), `sprint_id`,
* `release_id`, and `automation_column_id` (the column whose automation last
* ran, the re-trigger guard).
* - `task_board_comments` — comment stream per task, one level of replies via
* `parent_id`.
* - `task_board_attachments` — files/images on a task or a comment; bytes live
* in the row (capped at the tool layer), served by an org-scoped route.
* - `task_board_sprints` / `task_board_releases` — the optional planning
* entities behind the settings toggles.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable("organization_settings")
.addColumn("task_board", "jsonb")
.execute();

await db.schema
.alterTable("task_board_items")
.addColumn("column_id", "text")
.addColumn("tags", "jsonb")
.addColumn("sprint_id", "text")
.addColumn("release_id", "text")
.addColumn("automation_column_id", "text")
.execute();

await db.schema
.createTable("task_board_comments")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
.addColumn("task_board_item_id", "text", (col) =>
col.notNull().references("task_board_items.id").onDelete("cascade"),
)
.addColumn("parent_id", "text", (col) =>
col.references("task_board_comments.id").onDelete("cascade"),
)
.addColumn("body", "text", (col) => col.notNull())
.addColumn("created_by", "text", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();

await db.schema
.createIndex("idx_task_board_comments_item")
.on("task_board_comments")
.columns(["organization_id", "task_board_item_id"])
.execute();

await db.schema
.createTable("task_board_attachments")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
.addColumn("task_board_item_id", "text", (col) =>
col.notNull().references("task_board_items.id").onDelete("cascade"),
)
.addColumn("comment_id", "text", (col) =>
col.references("task_board_comments.id").onDelete("cascade"),
)
.addColumn("filename", "text", (col) => col.notNull())
.addColumn("mime_type", "text", (col) => col.notNull())
.addColumn("size", "integer", (col) => col.notNull())
.addColumn("data", "bytea", (col) => col.notNull())
.addColumn("created_by", "text", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();

await db.schema
.createIndex("idx_task_board_attachments_item")
.on("task_board_attachments")
.columns(["organization_id", "task_board_item_id"])
.execute();

await db.schema
.createTable("task_board_sprints")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
.addColumn("name", "text", (col) => col.notNull())
.addColumn("state", "text", (col) => col.notNull().defaultTo("planned"))
.addColumn("start_date", "timestamptz")
.addColumn("end_date", "timestamptz")
.addColumn("created_by", "text", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();

await db.schema
.createTable("task_board_releases")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
.addColumn("title", "text", (col) => col.notNull())
.addColumn("notes", "text")
.addColumn("created_by", "text", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("task_board_releases").execute();
await db.schema.dropTable("task_board_sprints").execute();
await db.schema.dropIndex("idx_task_board_attachments_item").execute();
await db.schema.dropTable("task_board_attachments").execute();
await db.schema.dropIndex("idx_task_board_comments_item").execute();
await db.schema.dropTable("task_board_comments").execute();
await db.schema
.alterTable("task_board_items")
.dropColumn("column_id")
.dropColumn("tags")
.dropColumn("sprint_id")
.dropColumn("release_id")
.dropColumn("automation_column_id")
.execute();
await db.schema
.alterTable("organization_settings")
.dropColumn("task_board")
.execute();
}
40 changes: 40 additions & 0 deletions apps/api/migrations/151-task-board-item-seq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type Kysely, sql } from "kysely";

/**
* Per-org sequential task number (`seq`) — a short, human-friendly key shown on
* cards and the task modal (e.g. "#42"), the way trackers surface PROJ-123.
* Assigned as max(seq)+1 per org at create time. Backfilled here by created_at
* order so existing tasks get stable numbers.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable("task_board_items")
.addColumn("seq", "integer")
.execute();

// Number existing rows per org, oldest first (id breaks created_at ties).
await sql`
UPDATE task_board_items AS t
SET seq = s.rn
FROM (
SELECT id,
row_number() OVER (
PARTITION BY organization_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM task_board_items
) AS s
WHERE t.id = s.id
`.execute(db);

await db.schema
.createIndex("idx_task_board_items_org_seq")
.on("task_board_items")
.columns(["organization_id", "seq"])
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropIndex("idx_task_board_items_org_seq").execute();
await db.schema.alterTable("task_board_items").dropColumn("seq").execute();
}
38 changes: 38 additions & 0 deletions apps/api/migrations/152-task-board-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type Kysely, sql } from "kysely";

/**
* Task activity log — the "changes of the card" timeline (created, status
* moved, (re)assigned, sprint changed), rendered inline in the task's Activity
* feed alongside comments, agent sessions and PRs. Append-only; one row per
* event, with the actor and a jsonb `data` payload ({from,to,...}).
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("task_board_activity")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
.addColumn("task_board_item_id", "text", (col) =>
col.notNull().references("task_board_items.id").onDelete("cascade"),
)
.addColumn("kind", "text", (col) => col.notNull())
/** User id, or a sentinel ("system"/"super-agent") for machine actors. */
.addColumn("actor_id", "text")
.addColumn("data", "jsonb")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();

await db.schema
.createIndex("idx_task_board_activity_item")
.on("task_board_activity")
.columns(["organization_id", "task_board_item_id", "created_at"])
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropIndex("idx_task_board_activity_item").execute();
await db.schema.dropTable("task_board_activity").execute();
}
6 changes: 6 additions & 0 deletions apps/api/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ import * as migration146organizationmainagent from "./146-organization-main-agen
import * as migration147dropagentsandboxtables from "./147-drop-agent-sandbox-tables.ts";
import * as migration148orgflags from "./148-org-flags.ts";
import * as migration149taskboarditemsortorder from "./149-task-board-item-sort-order.ts";
import * as migration150taskboardfeatures from "./150-task-board-features.ts";
import * as migration151taskboarditemseq from "./151-task-board-item-seq.ts";
import * as migration152taskboardactivity from "./152-task-board-activity.ts";

/**
* Core migrations for the Studio application.
Expand Down Expand Up @@ -321,6 +324,9 @@ const migrations: Record<string, Migration> = {
"147-drop-agent-sandbox-tables": migration147dropagentsandboxtables,
"148-org-flags": migration148orgflags,
"149-task-board-item-sort-order": migration149taskboarditemsortorder,
"150-task-board-features": migration150taskboardfeatures,
"151-task-board-item-seq": migration151taskboarditemseq,
"152-task-board-activity": migration152taskboardactivity,
};

export default migrations;
2 changes: 2 additions & 0 deletions apps/api/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createProxyRoutes } from "./proxy";
import { createSelfRoutes } from "./self";
import { createHomeNextActionsRoutes } from "./home-next-actions";
import { createCommerceDiagnosticShareRoutes } from "./commerce-diagnostic-share";
import { createTaskBoardAttachmentRoutes } from "./task-board-attachments";
import { createTaskBoardImportRoutes } from "./task-board-import";
import { createObjectStorageRoutes } from "./object-storage";
import { createThreadOutputsRoutes } from "./thread-outputs";
Expand Down Expand Up @@ -88,6 +89,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => {
app.route("/", createDownstreamTokenRoutes()); // /api/:org/connections/:connectionId/oauth-token
app.route("/", createCredentialVaultRoutes()); // /api/:org/vault/connections/:connectionId/access-token
app.route("/", createTaskBoardImportRoutes()); // /api/:org/internal/task-board/import — service-token batch import
app.route("/", createTaskBoardAttachmentRoutes()); // /api/:org/task-board/attachments/:id — attachment bytes
app.route("/", createCommerceDiagnosticShareRoutes()); // /api/:org/internal/commerce-diagnostic/share-invite — service-token share invite
app.route("/", createThreadOutputsRoutes()); // /api/:org/threads/:threadId/outputs
app.route("/tools", createToolsRestRoutes()); // /api/:org/tools[/:toolName] — REST builtin-tool dispatch
Expand Down
65 changes: 65 additions & 0 deletions apps/api/src/api/routes/task-board-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Task board attachment bytes.
*
* Route: GET /api/:org/task-board/attachments/:id
*
* Uploads go through the TASK_BOARD_ATTACHMENT_ADD / TASK_BOARD_COMMENT_CREATE
* tools (base64, size-capped); this route only streams the stored bytes back
* so <img> tags and download links work. Org membership is enforced by
* `resolveOrgFromPath` on the org-scoped sub-app; the storage read is
* additionally keyed by the resolved org id.
*/

import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import type { StudioContext } from "@/core/studio-context";

type Variables = { studioContext: StudioContext };

/** Only render-safe media inline; everything else downloads. */
const INLINE_MIME = /^(image\/|video\/|audio\/|application\/pdf$|text\/plain$)/;

export const createTaskBoardAttachmentRoutes = () => {
const app = new Hono<{ Variables: Variables }>();

app.get("/task-board/attachments/:id", async (c) => {
const ctx = c.get("studioContext");
if (!ctx.auth?.user?.id) {
throw new HTTPException(401, { message: "Unauthorized" });
}
const orgId = ctx.organization?.id;
if (!orgId) {
throw new HTTPException(400, { message: "Organization required" });
}

const id = c.req.param("id");
if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) {
throw new HTTPException(400, { message: "Invalid attachment ID" });
}

const attachment = await ctx.storage.taskBoard.getAttachment(id, orgId);
if (!attachment) {
throw new HTTPException(404, { message: "Attachment not found" });
}

const { meta, data } = attachment;
// Re-view over a plain ArrayBuffer — Hono's Data type rejects a
// SharedArrayBuffer-backed view, which pg's driver types don't rule out.
const body = new Uint8Array(data.slice().buffer) as Uint8Array<ArrayBuffer>;
const inline = INLINE_MIME.test(meta.mimeType);
// RFC 5987 filename* handles non-ASCII names; plain filename is a fallback.
const asciiName = meta.filename
.replace(/[^\x20-\x7e]/g, "_")
.replace(/"/g, "'");
return c.body(body, 200, {
"Content-Type": inline ? meta.mimeType : "application/octet-stream",
"Content-Length": String(meta.size),
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(meta.filename)}`,
// Attachment bytes are immutable (delete + re-add mints a new id).
"Cache-Control": "private, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
});
});

return app;
};
12 changes: 12 additions & 0 deletions apps/api/src/storage/organization-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export class OrganizationSettingsStorage
: record.flags
: null,
main_agent_id: record.main_agent_id ?? null,
task_board: record.task_board
? typeof record.task_board === "string"
? JSON.parse(record.task_board)
: record.task_board
: null,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
Expand All @@ -68,6 +73,7 @@ export class OrganizationSettingsStorage
| "default_home_agents"
| "flags"
| "main_agent_id"
| "task_board"
>
>,
): Promise<OrganizationSettings> {
Expand All @@ -88,6 +94,9 @@ export class OrganizationSettingsStorage
? JSON.stringify(data.default_home_agents)
: null;
const flagsJson = data?.flags ? JSON.stringify(data.flags) : null;
const taskBoardJson = data?.task_board
? JSON.stringify(data.task_board)
: null;
await this.db
.insertInto("organization_settings")
.values({
Expand All @@ -99,6 +108,7 @@ export class OrganizationSettingsStorage
default_home_agents: defaultHomeAgentsJson,
flags: flagsJson,
main_agent_id: data?.main_agent_id ?? null,
task_board: taskBoardJson,
createdAt: now,
updatedAt: now,
})
Expand All @@ -120,6 +130,7 @@ export class OrganizationSettingsStorage
// Nullable id: explicit `null` clears the main agent; `undefined`
// (field absent) skips the column so partial updates don't wipe it.
main_agent_id: data?.main_agent_id,
task_board: taskBoardJson ? taskBoardJson : undefined,
updatedAt: now,
}),
)
Expand All @@ -137,6 +148,7 @@ export class OrganizationSettingsStorage
default_home_agents: data?.default_home_agents ?? null,
flags: data?.flags ?? null,
main_agent_id: data?.main_agent_id ?? null,
task_board: data?.task_board ?? null,
createdAt: now,
updatedAt: now,
};
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/storage/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export interface OrganizationSettingsStoragePort {
| "default_home_agents"
| "flags"
| "main_agent_id"
| "task_board"
>
>,
): Promise<OrganizationSettings>;
Expand Down
Loading
Loading