Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/ee/convex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This directory contains the Convex backend for Agendex Cloud / EE. It powers aut
- `plans.ts` - EE plan retrieval helpers and shared plan access
- `planVisibility.ts` - shared low-value plan classification on ingest, metadata merge, and visibility gates for reads
- `planVersions.ts` - plan history listing, snapshot reads, and restore flow
- `planVersioning.ts` - shared helpers that write `planVersions` snapshots on create, CLI sync, editor save, and restore
- `planCleanup.ts` - internal dry-run audit and apply cleanup for existing low-value cloud rows and Codex subagent/title-family clones (maintainer-only)
- `sharing.ts` - create and revoke share links
- `comments.ts` - read, create, and delete plan comments
Expand Down
92 changes: 86 additions & 6 deletions packages/ee/convex/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
mergePlanMetadata,
metadataWithPlanValueAssessment,
} from './planVisibility';
import { ensureBaselinePlanVersion, planContentChanged, recordPlanVersion } from './planVersioning';
import { stripLocalIpFromMetadata } from './privacy';

const DAEMON_HEARTBEAT_RETENTION_MS = 7 * 86_400_000;
Expand Down Expand Up @@ -355,23 +356,85 @@ export const upsertPlan = internalMutation({
handler: async (ctx, args) => {
const now = Date.now();
const continuityKey = plannotatorContinuityKey(args.metadata, args.filePath);
const updatedAt = args.updatedAt ?? now;

if (args.existingId && args.existingVersion !== undefined) {
await ctx.db.patch(args.existingId, {
agent: args.agent,
const existing = await ctx.db.get(args.existingId);
const contentChanged = existing ? planContentChanged(existing, args) : true;

// Non-content field updates (format/path/workspace/identity) still patch the
// live row, but must not create empty "CLI sync" history entries.
if (!contentChanged) {
await ctx.db.patch(args.existingId, {
agent: args.agent,
title: args.title,
content: args.content,
format: args.format,
filePath: args.filePath,
workspace: args.workspace,
metadata: args.metadata,
...(continuityKey ? { plannotatorContinuityKey: continuityKey } : {}),
syncIdentityKey: args.syncIdentityKey,
contentHash: args.contentHash,
identityVersion: args.identityVersion,
identityStrength: args.identityStrength,
updatedAt,
});
await markSupersededPlannotatorSessions(ctx, {
ownerId: args.ownerId,
canonicalPlanId: args.existingId,
canonicalLocalPlanId: args.localPlanId,
metadata: args.metadata,
filePath: args.filePath,
now,
});
return args.existingId;
}

if (existing) {
await ensureBaselinePlanVersion(ctx, {
ownerId: args.ownerId,
planId: args.existingId,
version: args.existingVersion,
snapshot: {
title: existing.title,
content: existing.content,
format: existing.format,
filePath: existing.filePath,
workspace: existing.workspace,
metadata: existing.metadata,
},
createdAt: existing.updatedAt,
});
}

const newVersion = args.existingVersion + 1;
const snapshot = {
title: args.title,
content: args.content,
format: args.format,
filePath: args.filePath,
workspace: args.workspace,
metadata: args.metadata,
};
await ctx.db.patch(args.existingId, {
agent: args.agent,
...snapshot,
...(continuityKey ? { plannotatorContinuityKey: continuityKey } : {}),
syncIdentityKey: args.syncIdentityKey,
contentHash: args.contentHash,
identityVersion: args.identityVersion,
identityStrength: args.identityStrength,
version: args.existingVersion + 1,
updatedAt: args.updatedAt ?? now,
version: newVersion,
updatedAt,
});
await recordPlanVersion(ctx, {
ownerId: args.ownerId,
planId: args.existingId,
version: newVersion,
snapshot,
source: 'cli_sync',
createdAt: updatedAt,
});
Comment thread
Tyru5 marked this conversation as resolved.
await markSupersededPlannotatorSessions(ctx, {
ownerId: args.ownerId,
Expand All @@ -384,6 +447,7 @@ export const upsertPlan = internalMutation({
return args.existingId;
}

const createdAt = args.createdAt ?? now;
const planId = await ctx.db.insert('plans', {
ownerId: args.ownerId,
localPlanId: args.localPlanId,
Expand All @@ -400,8 +464,24 @@ export const upsertPlan = internalMutation({
identityVersion: args.identityVersion,
identityStrength: args.identityStrength,
version: 1,
createdAt: args.createdAt ?? now,
updatedAt: args.updatedAt ?? now,
createdAt,
updatedAt,
});

await recordPlanVersion(ctx, {
ownerId: args.ownerId,
planId,
version: 1,
snapshot: {
title: args.title,
content: args.content,
format: args.format,
filePath: args.filePath,
workspace: args.workspace,
metadata: args.metadata,
},
source: 'cli_sync',
createdAt,
});

await markSupersededPlannotatorSessions(ctx, {
Expand Down
23 changes: 23 additions & 0 deletions packages/ee/convex/planVersioning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test';
import { planContentChanged } from './planVersioning';

test('planContentChanged is false when title and content match', () => {
expect(
planContentChanged(
{ title: 'Plan', content: '# Steps\n- do thing\n' },
{ title: 'Plan', content: '# Steps\n- do thing\n' },
),
).toBe(false);
});

test('planContentChanged is true when title changes', () => {
expect(
planContentChanged({ title: 'Plan', content: 'body' }, { title: 'Plan v2', content: 'body' }),
).toBe(true);
});

test('planContentChanged is true when content changes', () => {
expect(
planContentChanged({ title: 'Plan', content: 'old' }, { title: 'Plan', content: 'new' }),
).toBe(true);
});
82 changes: 82 additions & 0 deletions packages/ee/convex/planVersioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';

export type PlanVersionSource = 'cli_sync' | 'editor' | 'restore' | 'backfill';

export type PlanVersionSnapshot = {
title: string;
content: string;
format: string;
filePath?: string;
workspace?: string;
metadata?: unknown;
};

type PlanVersionWriteCtx = Pick<MutationCtx, 'db'>;

/** True when title or body changed enough to warrant a new history snapshot. */
export function planContentChanged(
previous: { title: string; content: string },
next: { title: string; content: string },
): boolean {
return previous.title !== next.title || previous.content !== next.content;
}

/** Insert an immutable planVersions row for the given plan version number. */
export async function recordPlanVersion(
ctx: PlanVersionWriteCtx,
args: {
ownerId: string;
planId: Id<'plans'>;
version: number;
snapshot: PlanVersionSnapshot;
source?: PlanVersionSource;
createdAt?: number;
},
): Promise<Id<'planVersions'>> {
return await ctx.db.insert('planVersions', {
ownerId: args.ownerId,
planId: args.planId,
version: args.version,
title: args.snapshot.title,
content: args.snapshot.content,
format: args.snapshot.format,
filePath: args.snapshot.filePath,
workspace: args.snapshot.workspace,
metadata: args.snapshot.metadata,
...(args.source ? { source: args.source } : {}),
createdAt: args.createdAt ?? Date.now(),
});
}

/**
* If a plan has never been snapshotted (common for rows created before history
* was wired into sync), capture the current live content at `version` so the
* next bump does not lose the pre-change state.
*/
export async function ensureBaselinePlanVersion(
ctx: PlanVersionWriteCtx,
args: {
ownerId: string;
planId: Id<'plans'>;
version: number;
snapshot: PlanVersionSnapshot;
createdAt?: number;
},
): Promise<boolean> {
const existing = await ctx.db
.query('planVersions')
.withIndex('by_plan', (q) => q.eq('planId', args.planId))
.first();
if (existing) return false;

await recordPlanVersion(ctx, {
ownerId: args.ownerId,
planId: args.planId,
version: args.version,
snapshot: args.snapshot,
source: 'backfill',
createdAt: args.createdAt,
});
return true;
}
Comment thread
Tyru5 marked this conversation as resolved.
16 changes: 8 additions & 8 deletions packages/ee/convex/planVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mutation, query } from './_generated/server';
import { authComponent } from './auth';
import { requireFeature } from './entitlements';
import { assessPlanForVisibility, metadataWithPlanValueAssessment } from './planVisibility';
import { recordPlanVersion } from './planVersioning';

export const listForPlan = query({
args: { planId: v.id('plans') },
Expand Down Expand Up @@ -124,27 +125,26 @@ export const restore = mutation({
content: snapshot.content,
});

await ctx.db.patch(args.planId, {
const restoredSnapshot = {
title: snapshot.title,
content: snapshot.content,
format: snapshot.format,
filePath: snapshot.filePath,
workspace: snapshot.workspace,
metadata,
};

await ctx.db.patch(args.planId, {
...restoredSnapshot,
version: newVersion,
updatedAt: now,
});

await ctx.db.insert('planVersions', {
await recordPlanVersion(ctx, {
ownerId: user._id,
planId: args.planId,
version: newVersion,
title: snapshot.title,
content: snapshot.content,
format: snapshot.format,
filePath: snapshot.filePath,
workspace: snapshot.workspace,
metadata,
snapshot: restoredSnapshot,
source: 'restore',
createdAt: now,
});
Expand Down
Loading
Loading