Skip to content

Commit b55e266

Browse files
committed
fix: harden GitHub backups
1 parent 2492a52 commit b55e266

File tree

9 files changed

+226
-110
lines changed

9 files changed

+226
-110
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ count.txt
1616
.wrangler
1717
.output
1818
.vinxi
19+
*.bun-build
1920
todos.json
2021
.cta.json
2122
.vscode

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66
- API: v1 public REST endpoints with rate limits, raw file fetch, and OpenAPI spec.
77
- Docs: `docs/api.md` and `DEPRECATIONS.md` for the v1 cutover plan.
8+
- Registry: GitHub App backs up published skills to `clawdbot/skills` (thanks @thewilloftheshadow, #5).
89

910
### Changed
1011
- CLI: publish now uses single multipart `POST /api/v1/skills`.

convex/_generated/api.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import type * as auth from "../auth.js";
1212
import type * as comments from "../comments.js";
13+
import type * as crons from "../crons.js";
1314
import type * as downloads from "../downloads.js";
1415
import type * as githubBackups from "../githubBackups.js";
1516
import type * as githubBackupsNode from "../githubBackupsNode.js";
@@ -20,6 +21,7 @@ import type * as lib_access from "../lib/access.js";
2021
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
2122
import type * as lib_changelog from "../lib/changelog.js";
2223
import type * as lib_embeddings from "../lib/embeddings.js";
24+
import type * as lib_githubBackup from "../lib/githubBackup.js";
2325
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
2426
import type * as lib_skillPublish from "../lib/skillPublish.js";
2527
import type * as lib_skills from "../lib/skills.js";
@@ -45,6 +47,7 @@ import type {
4547
declare const fullApi: ApiFromModules<{
4648
auth: typeof auth;
4749
comments: typeof comments;
50+
crons: typeof crons;
4851
downloads: typeof downloads;
4952
githubBackups: typeof githubBackups;
5053
githubBackupsNode: typeof githubBackupsNode;
@@ -55,6 +58,7 @@ declare const fullApi: ApiFromModules<{
5558
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
5659
"lib/changelog": typeof lib_changelog;
5760
"lib/embeddings": typeof lib_embeddings;
61+
"lib/githubBackup": typeof lib_githubBackup;
5862
"lib/skillBackfill": typeof lib_skillBackfill;
5963
"lib/skillPublish": typeof lib_skillPublish;
6064
"lib/skills": typeof lib_skills;

convex/crons.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ crons.interval(
77
'github-backup-sync',
88
{ minutes: 30 },
99
internal.githubBackupsNode.syncGitHubBackupsInternal,
10-
{},
10+
{ batchSize: 50, maxBatches: 5 },
1111
)
1212

1313
export default crons

convex/githubBackups.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { v } from 'convex/values'
22
import { internal } from './_generated/api'
33
import type { Doc, Id } from './_generated/dataModel'
4-
import { action, internalQuery } from './_generated/server'
4+
import { action, internalMutation, internalQuery } from './_generated/server'
55
import { assertRole, requireUserFromAction } from './lib/access'
66

7-
const MAX_BATCH_SIZE = 500
8-
const DEFAULT_BATCH_SIZE = MAX_BATCH_SIZE
7+
const DEFAULT_BATCH_SIZE = 50
8+
const MAX_BATCH_SIZE = 200
9+
const SYNC_STATE_KEY = 'default'
910

1011
type BackupPageItem =
1112
| {
@@ -29,6 +30,23 @@ type BackupPageResult = {
2930
isDone: boolean
3031
}
3132

33+
type BackupSyncState = {
34+
cursor: string | null
35+
}
36+
37+
export type SyncGitHubBackupsResult = {
38+
stats: {
39+
skillsScanned: number
40+
skillsSkipped: number
41+
skillsBackedUp: number
42+
skillsMissingVersion: number
43+
skillsMissingOwner: number
44+
errors: number
45+
}
46+
cursor: string | null
47+
isDone: boolean
48+
}
49+
3250
export const getGitHubBackupPageInternal = internalQuery({
3351
args: {
3452
cursor: v.optional(v.string()),
@@ -72,7 +90,7 @@ export const getGitHubBackupPageInternal = internalQuery({
7290
slug: skill.slug,
7391
displayName: skill.displayName,
7492
version: version.version,
75-
ownerHandle: owner.handle ?? owner.name ?? owner.email ?? owner._id,
93+
ownerHandle: owner.handle ?? owner._id,
7694
files: version.files,
7795
publishedAt: version.createdAt,
7896
})
@@ -82,20 +100,68 @@ export const getGitHubBackupPageInternal = internalQuery({
82100
},
83101
})
84102

85-
export const syncGitHubBackups = action({
103+
export const getGitHubBackupSyncStateInternal = internalQuery({
104+
args: {},
105+
handler: async (ctx): Promise<BackupSyncState> => {
106+
const state = await ctx.db
107+
.query('githubBackupSyncState')
108+
.withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
109+
.unique()
110+
return { cursor: state?.cursor ?? null }
111+
},
112+
})
113+
114+
export const setGitHubBackupSyncStateInternal = internalMutation({
115+
args: {
116+
cursor: v.optional(v.string()),
117+
},
118+
handler: async (ctx, args) => {
119+
const now = Date.now()
120+
const state = await ctx.db
121+
.query('githubBackupSyncState')
122+
.withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
123+
.unique()
124+
125+
if (!state) {
126+
await ctx.db.insert('githubBackupSyncState', {
127+
key: SYNC_STATE_KEY,
128+
cursor: args.cursor,
129+
updatedAt: now,
130+
})
131+
return { ok: true as const }
132+
}
133+
134+
await ctx.db.patch(state._id, {
135+
cursor: args.cursor,
136+
updatedAt: now,
137+
})
138+
139+
return { ok: true as const }
140+
},
141+
})
142+
143+
export const syncGitHubBackups: ReturnType<typeof action> = action({
86144
args: {
87145
dryRun: v.optional(v.boolean()),
88146
batchSize: v.optional(v.number()),
89147
maxBatches: v.optional(v.number()),
148+
resetCursor: v.optional(v.boolean()),
90149
},
91-
handler: async (ctx, args) => {
150+
handler: async (ctx, args): Promise<SyncGitHubBackupsResult> => {
92151
const { user } = await requireUserFromAction(ctx)
93152
assertRole(user, ['admin'])
153+
154+
if (args.resetCursor && !args.dryRun) {
155+
await ctx.runMutation(internal.githubBackups.setGitHubBackupSyncStateInternal, {
156+
cursor: undefined,
157+
})
158+
}
159+
94160
return ctx.runAction(internal.githubBackupsNode.syncGitHubBackupsInternal, {
95161
dryRun: args.dryRun,
96162
batchSize: args.batchSize,
97163
maxBatches: args.maxBatches,
98-
})
164+
}) as Promise<SyncGitHubBackupsResult>
99165
},
100166
})
101167

0 commit comments

Comments
 (0)