Merge remote-tracking branch 'origin/master' into PAP-12383-what-are-the-organizational-patterns-for-things-like-zapier-and-other-similar-tools

# Conflicts:
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta committed 2026-07-06 19:53:35 +00:00
commit ae04bca734
71 files changed
+7354 -329

No files matched your search

@@ -78,8 +78,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const archivedProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const terminatedAgentId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
@@ -96,6 +98,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
]);
await db.insert(projects).values([
{ id: projectId, companyId, name: "Growth", status: "in_progress" },
{ id: archivedProjectId, companyId, name: "Archived", status: "completed", archivedAt: new Date() },
{ id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" },
]);
await db.insert(agents).values([
@@ -121,8 +124,19 @@ describeEmbeddedPostgres("resource membership routes", () => {
runtimeConfig: {},
permissions: {},
},
{
id: terminatedAgentId,
companyId,
name: "Terminated",
role: "engineer",
status: "terminated",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, otherAgentId, otherProjectId, projectId, agentId };
return { archivedProjectId, companyId, otherAgentId, otherProjectId, projectId, agentId, terminatedAgentId };
}
it("defaults missing membership rows to joined", async () => {
@@ -135,6 +149,10 @@ describeEmbeddedPostgres("resource membership routes", () => {
expect(res.body).toEqual({
projectMemberships: {},
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
projectStarredAt: {},
agentStarredAt: {},
updatedAt: null,
});
});
@@ -151,7 +169,7 @@ describeEmbeddedPostgres("resource membership routes", () => {
.send({ state: "left" });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" });
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left", starredAt: null });
expect(second.status).toBe(200);
const rows = await db.select().from(projectMemberships);
@@ -170,6 +188,174 @@ describeEmbeddedPostgres("resource membership routes", () => {
});
});
it("stars projects idempotently and exposes starred project contract data", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId, "viewer"));
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true });
const list = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "joined" });
expect(first.body.starredAt).toEqual(expect.any(String));
expect(second.status).toBe(200);
expect(second.body.starredAt).toBe(first.body.starredAt);
expect(list.body.starredProjectIds).toEqual([projectId]);
expect(list.body.projectStarredAt[projectId]).toEqual(first.body.starredAt);
const rows = await db.select().from(projectMemberships);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "joined" });
expect(rows[0]?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity).toHaveLength(1);
expect(activity[0]).toMatchObject({
action: "resource_membership.starred",
entityType: "project",
entityId: projectId,
});
expect(activity[0]?.details).toMatchObject({
userId: "user-1",
resourceType: "project",
resourceId: projectId,
state: "joined",
starred: true,
});
});
it("clears starred_at when leaving a starred resource", async () => {
const { companyId, projectId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ starred: true })
.expect(200);
const leave = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`)
.send({ state: "left" });
expect(leave.status).toBe(200);
expect(leave.body).toMatchObject({ state: "left", starredAt: null });
const [row] = await db.select().from(projectMemberships);
expect(row).toMatchObject({ state: "left", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.left",
]);
expect(activity[1]?.details).toMatchObject({ state: "left", starred: false, starredAt: null });
});
it("starring a left resource rejoins it", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ state: "left" })
.expect(200);
const star = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true });
expect(star.status).toBe(200);
expect(star.body).toMatchObject({ resourceType: "agent", resourceId: agentId, state: "joined" });
expect(star.body.starredAt).toEqual(expect.any(String));
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined" });
expect(row?.starredAt).toBeInstanceOf(Date);
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.left",
"resource_membership.starred",
]);
});
it("unstars agents idempotently without requiring a state change", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, boardActor(companyId));
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: true })
.expect(200);
const first = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
const second = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${agentId}`)
.send({ starred: false });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({ state: "joined", starredAt: null });
expect(second.status).toBe(200);
expect(second.body).toMatchObject({ state: "joined", starredAt: null });
const [row] = await db.select().from(agentMemberships);
expect(row).toMatchObject({ state: "joined", starredAt: null });
const activity = await db.select().from(activityLog);
expect(activity.map((entry) => entry.action)).toEqual([
"resource_membership.starred",
"resource_membership.unstarred",
]);
});
it("omits archived projects and terminated agents from starred sidebar data", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const starredAt = new Date();
await db.insert(projectMemberships).values({
companyId,
projectId: archivedProjectId,
userId: "user-1",
state: "joined",
starredAt,
});
await db.insert(agentMemberships).values({
companyId,
agentId: terminatedAgentId,
userId: "user-1",
state: "joined",
starredAt,
});
const app = createApp(db, boardActor(companyId));
const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`);
expect(res.status).toBe(200);
expect(res.body.projectMemberships[archivedProjectId]).toBe("joined");
expect(res.body.agentMemberships[terminatedAgentId]).toBe("joined");
expect(res.body.starredProjectIds).toEqual([]);
expect(res.body.starredAgentIds).toEqual([]);
expect(res.body.projectStarredAt).toEqual({});
expect(res.body.agentStarredAt).toEqual({});
});
it("rejects starring archived projects and terminated agents", async () => {
const { archivedProjectId, companyId, terminatedAgentId } = await seed();
const app = createApp(db, boardActor(companyId));
const projectRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/projects/${archivedProjectId}`)
.send({ starred: true });
const agentRes = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/agents/${terminatedAgentId}`)
.send({ starred: true });
expect(projectRes.status).toBe(404);
expect(agentRes.status).toBe(404);
});
it("rejects agent API key actors", async () => {
const { companyId, agentId } = await seed();
const app = createApp(db, {
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { parseGitDescribeVersion, resolveServerVersion } from "../version.js";
describe("parseGitDescribeVersion", () => {
it("reports drift from the nearest release tag as a PEP 440 local version", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce\n")).toBe(
"2026.626.0+58.git.518fc71ce",
);
});
it("collapses clean on-tag checkouts to the release version", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678\n")).toBe("2026.626.0");
});
it("adds dirty state to the local version segment", () => {
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce-dirty\n")).toBe(
"2026.626.0+58.git.518fc71ce.dirty",
);
});
it("returns null for unparseable describe output so callers can fall back", () => {
expect(parseGitDescribeVersion("canary/v2026.706.0-canary.1")).toBeNull();
});
it("appends dirty suffix even when on-tag (zero commits since tag)", () => {
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678-dirty\n")).toBe(
"2026.626.0+0.git.012345678.dirty",
);
});
});
describe("resolveServerVersion", () => {
it("returns the parsed git-derived version when git describe succeeds", () => {
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n",
debugLog: vi.fn(),
}),
).toBe("2026.626.0+58.git.518fc71ce");
});
it("falls back to package version without throwing when git is unavailable", () => {
const debugLog = vi.fn();
expect(
resolveServerVersion({
packageVersion: "2026.706.0",
gitDescribeCommand: () => {
throw new Error("fatal: not a git repository");
},
debugLog,
}),
).toBe("2026.706.0");
expect(debugLog).toHaveBeenCalledWith(
expect.objectContaining({ reason: "git_describe_unavailable" }),
"falling back to package version for server version",
);
});
});
+95 -2
View File
@@ -1500,6 +1500,99 @@ describe("realizeExecutionWorkspace", () => {
);
}, 30_000);
it("reinstalls worktree-local pnpm dependencies when package metadata changes", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-deps-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const installLogPath = path.join(tempRoot, "install.log");
try {
await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true });
await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(worktreeRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "pnpm-lock.yaml"),
["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify({ name: "ui", private: true, dependencies: {} }, null, 2),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
` echo "install:$*" >> ${JSON.stringify(installLogPath)}`,
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const runScript = () => execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
await runScript();
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\n",
);
await fs.writeFile(
path.join(worktreeRoot, "ui", "package.json"),
JSON.stringify(
{ name: "ui", private: true, dependencies: { "@xterm/addon-fit": "^0.11.0" } },
null,
2,
),
"utf8",
);
await runScript();
await expect(fs.readFile(installLogPath, "utf8")).resolves.toBe(
"install:install --prod=false --frozen-lockfile\ninstall:install --prod=false --frozen-lockfile\n",
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}, 30_000);
it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-"));
const baseRoot = path.join(tempRoot, "base");
@@ -1686,11 +1779,11 @@ describe("realizeExecutionWorkspace", () => {
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--frozen-lockfile\" ]; then",
" echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2",
" exit 1",
"fi",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then",
"if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--prod=false\" ] && [ \"$3\" = \"--no-frozen-lockfile\" ]; then",
" mkdir -p \"$PWD/node_modules\"",
" : > \"$PWD/node_modules/.retry-success\"",
" exit 0",
+3 -8
View File
@@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
agentMineInboxQuerySchema,
ADAPTER_AGNOSTIC_KEYS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
createAgentKeySchema,
createAgentHireSchema,
@@ -170,6 +171,7 @@ export function agentRoutes(
"instructionsFilePath",
"agentsMdPath",
] as const;
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
const svc = agentService(db);
@@ -2871,15 +2873,8 @@ export function agentRoutes(
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
// when the adapter type changes. Without this, a PATCH that includes
// adapterConfig but omits these keys would silently drop them.
// `paperclipSkillSync` holds the agent's desired-skill selection, which is
// a company-level (adapter-agnostic) choice even though it is persisted
// inside the per-adapter config; switching adapters must not wipe it.
const ADAPTER_AGNOSTIC_KEYS = [
"env", "cwd", "timeoutSec", "graceSec",
"promptTemplate", "bootstrapPromptTemplate",
"paperclipSkillSync",
] as const;
for (const key of ADAPTER_AGNOSTIC_KEYS) {
if (KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET.has(key)) continue;
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
}
+15 -5
View File
@@ -22,6 +22,8 @@ async function logMembershipChange(
resourceType: "project" | "agent";
resourceId: string;
state: "joined" | "left";
starredAt: Date | null;
changeKind: "joined" | "left" | "starred" | "unstarred";
policySource: string;
},
) {
@@ -32,7 +34,7 @@ async function logMembershipChange(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: `resource_membership.${input.state}`,
action: `resource_membership.${input.changeKind}`,
entityType: input.resourceType,
entityId: input.resourceId,
details: {
@@ -40,6 +42,8 @@ async function logMembershipChange(
resourceType: input.resourceType,
resourceId: input.resourceId,
state: input.state,
starredAt: input.starredAt,
starred: input.starredAt !== null,
policySource: input.policySource,
},
});
@@ -69,19 +73,22 @@ export function resourceMembershipRoutes(db: Db) {
projectId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "project",
resourceId: projectId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
@@ -99,19 +106,22 @@ export function resourceMembershipRoutes(db: Db) {
agentId,
userId,
state: req.body.state,
starred: req.body.starred,
actor: req.actor,
});
if (result.changed) {
if (result.changed && result.changeKind) {
await logMembershipChange(db, req, {
companyId,
userId,
resourceType: "agent",
resourceId: agentId,
state: result.state,
starredAt: result.starredAt,
changeKind: result.changeKind,
policySource: result.policySource,
});
}
const { changed: _changed, policySource: _policySource, ...response } = result;
const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result;
res.json(response);
},
);
+126 -30
View File
@@ -41,12 +41,21 @@ export type ResourceMembershipPolicyHook = (input: {
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}) => Promise<PolicyDecision> | PolicyDecision;
type ResourceMembershipServiceOptions = {
policyHook?: ResourceMembershipPolicyHook | null;
};
type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred";
type MembershipUpdateResult = ResourceMembershipUpdateResult & {
changed: boolean;
changeKind: MembershipChangeKind | null;
policySource: string;
};
function defaultJoinedMap<T extends { projectId?: string; agentId?: string; state: string }>(
rows: T[],
key: "projectId" | "agentId",
@@ -60,6 +69,30 @@ function defaultJoinedMap<T extends { projectId?: string; agentId?: string; stat
return result;
}
function starredAtMap<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, Date> {
const result: Record<string, Date> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string" || !row.starredAt) continue;
result[id] = row.starredAt;
}
return result;
}
function starredIds<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): string[] {
return rows
.filter((row) => row.starredAt)
.sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime())
.map((row) => row[key])
.filter((id): id is string => typeof id === "string");
}
function latestDate(...dates: Array<Date | null | undefined>): Date | null {
let latest: Date | null = null;
for (const date of dates) {
@@ -116,6 +149,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}): Promise<PolicyDecision> {
assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId);
const decision = await evaluatePolicy(policyHook, input);
@@ -144,9 +178,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
projectId: projectMemberships.projectId,
state: projectMemberships.state,
starredAt: projectMemberships.starredAt,
updatedAt: projectMemberships.updatedAt,
projectArchivedAt: projects.archivedAt,
})
.from(projectMemberships)
.innerJoin(projects, and(
eq(projects.id, projectMemberships.projectId),
eq(projects.companyId, projectMemberships.companyId),
))
.where(and(
eq(projectMemberships.companyId, companyId),
eq(projectMemberships.userId, userId),
@@ -155,17 +195,29 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
.select({
agentId: agentMemberships.agentId,
state: agentMemberships.state,
starredAt: agentMemberships.starredAt,
updatedAt: agentMemberships.updatedAt,
agentStatus: agents.status,
})
.from(agentMemberships)
.innerJoin(agents, and(
eq(agents.id, agentMemberships.agentId),
eq(agents.companyId, agentMemberships.companyId),
))
.where(and(
eq(agentMemberships.companyId, companyId),
eq(agentMemberships.userId, userId),
)),
]);
const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt);
const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated");
return {
projectMemberships: defaultJoinedMap(projectRows, "projectId"),
agentMemberships: defaultJoinedMap(agentRows, "agentId"),
starredProjectIds: starredIds(starEligibleProjectRows, "projectId"),
starredAgentIds: starredIds(starEligibleAgentRows, "agentId"),
projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"),
agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"),
updatedAt: latestDate(
...projectRows.map((row) => row.updatedAt),
...agentRows.map((row) => row.updatedAt),
@@ -177,24 +229,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
projectId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.companyId, input.companyId),
),
});
if (!project) throw notFound("Project not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: input.state,
});
if (!project || project.archivedAt) throw notFound("Project not found");
const existing = await db.query.projectMemberships.findFirst({
where: and(
@@ -204,13 +249,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "project",
resourceId: input.projectId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -222,13 +290,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
projectId: input.projectId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -238,8 +308,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "project",
resourceId: input.projectId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
@@ -248,24 +322,17 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: string;
userId: string;
agentId: string;
state: ResourceMembershipState;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<ResourceMembershipUpdateResult & { changed: boolean; policySource: string }> {
}): Promise<MembershipUpdateResult> {
const agent = await db.query.agents.findFirst({
where: and(
eq(agents.id, input.agentId),
eq(agents.companyId, input.companyId),
),
});
if (!agent) throw notFound("Agent not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
});
if (!agent || agent.status === "terminated") throw notFound("Agent not found");
const existing = await db.query.agentMemberships.findFirst({
where: and(
@@ -275,13 +342,36 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
if (previousState === input.state) {
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "agent",
resourceId: input.agentId,
state: input.state,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
@@ -293,13 +383,15 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId],
set: {
state: input.state,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
@@ -309,8 +401,12 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer
resourceType: "agent",
resourceId: input.agentId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
+71 -3
View File
@@ -1,10 +1,78 @@
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
type PackageJson = {
version?: string;
};
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as PackageJson;
type GitDescribeCommand = () => string;
type DebugLog = (fields: Record<string, unknown>, message: string) => void;
export const serverVersion = pkg.version ?? "0.0.0";
const requirePackage = createRequire(import.meta.url);
const pkg = requirePackage("../package.json") as PackageJson;
const GIT_DESCRIBE_RE =
/^v(?<publicVersion>\d+\.\d+\.\d+)-(?<commitsSinceTag>\d+)-g(?<sha>[0-9a-f]{7,40})(?<dirty>-dirty)?$/i;
function defaultDebugLog(fields: Record<string, unknown>, message: string): void {
console.debug(message, fields);
}
function defaultGitDescribeCommand(): string {
return execFileSync(
"git",
["describe", "--tags", "--match", "v*", "--long", "--dirty"],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
export function parseGitDescribeVersion(output: string): string | null {
const match = output.trim().match(GIT_DESCRIBE_RE);
if (!match?.groups) return null;
const publicVersion = match.groups.publicVersion;
const commitsSinceTag = match.groups.commitsSinceTag;
const sha = match.groups.sha;
const isDirty = Boolean(match.groups.dirty);
if (commitsSinceTag === "0" && !isDirty) {
return publicVersion;
}
return `${publicVersion}+${commitsSinceTag}.git.${sha}${isDirty ? ".dirty" : ""}`;
}
export function resolveServerVersion(
opts: {
gitDescribeCommand?: GitDescribeCommand;
packageVersion?: string;
debugLog?: DebugLog;
} = {},
): string {
const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0";
const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand;
const debugLog = opts.debugLog ?? defaultDebugLog;
try {
const parsedVersion = parseGitDescribeVersion(gitDescribeCommand());
if (parsedVersion) return parsedVersion;
debugLog(
{ reason: "invalid_git_describe" },
"falling back to package version for server version",
);
} catch (err) {
debugLog(
{ err, reason: "git_describe_unavailable" },
"falling back to package version for server version",
);
}
return packageVersion;
}
export const serverVersion = resolveServerVersion();