mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
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:
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, {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
Reference in new issue
Block a user