Skip to content

Commit 0a3034c

Browse files
Add skill archive artifacts
1 parent 063c8fb commit 0a3034c

15 files changed

Lines changed: 478 additions & 18 deletions

docs/epics/subagent-sync.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,18 @@ Bring SkillServer's skill artifact model closer to the Cloudflare RFC.
7272
Deliverables:
7373

7474
- Archive artifact generation for skills with resources.
75-
- Archive download endpoint for versioned skills.
75+
- `.zip` archive download endpoint for versioned skills at `/skills/{name}/{version}/archive.zip`.
7676
- RFC index emits `type: "archive"` for resourceful skills.
7777
- Existing per-file resource routes remain available for compatibility.
78+
- Additive archive artifact metadata that preserves existing `SKILL.md` digest behavior.
7879

7980
Acceptance criteria:
8081

8182
- `skill-md` entries digest the raw `SKILL.md` bytes.
8283
- `archive` entries digest the raw archive bytes.
8384
- Archives contain `SKILL.md` at the root.
8485
- Archive paths reject traversal and absolute paths.
86+
- Existing `SKILL.md` download and digest verification behavior remains compatible.
8587
- Existing resource tests keep passing or have explicit compatibility replacements.
8688

8789
### Phase 3: Native Manifest API
@@ -220,15 +222,17 @@ Add archive artifact support for skills with resources while preserving existing
220222

221223
Tasks:
222224
- [ ] Generate deterministic archives for resourceful skill versions.
223-
- [ ] Add archive download endpoint.
225+
- [ ] Add `.zip` archive download endpoint at `/skills/{name}/{version}/archive.zip`.
224226
- [ ] Emit `type: "archive"` in the RFC feed for resourceful skills.
225227
- [ ] Keep `type: "skill-md"` for single-file skills.
226228
- [ ] Preserve existing resource URLs for compatibility.
229+
- [ ] Preserve existing `SKILL.md` digest behavior by storing archive artifact metadata additively.
227230

228231
Acceptance criteria:
229232
- RFC feed artifact digests verify the exact bytes at `url`.
230233
- Archive entries contain `SKILL.md` at the archive root.
231234
- Path traversal and absolute paths are rejected.
235+
- Existing `SKILL.md` download and digest verification APIs remain compatible.
232236
- Integration tests cover `skill-md` and `archive` entries.
233237
```
234238

docs/specs/native-manifest.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ An identity index lists all versions for one skill or sub-agent.
142142
## Skill Version Detail
143143

144144
Skill version details wrap the exact artifact object that the RFC feed exposes for the same skill version.
145+
Resourceful skill artifacts use SkillServer's canonical `.zip` archive route, `/skills/{skillName}/{version}/archive.zip`.
145146

146147
```json
147148
{

docs/specs/skills.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ Target behavior:
119119

120120
- Skills with only `SKILL.md` should publish as `type: "skill-md"`.
121121
- Skills with supporting files should publish as `type: "archive"`.
122+
- SkillServer standardizes archive artifacts on `.zip` at `/skills/{name}/{version}/archive.zip`.
122123
- Archive artifacts should contain `SKILL.md` at the archive root plus supporting files under their relative paths.
123124
- The RFC feed and native manifest should point to the same artifact URL and digest for a given skill version.
125+
- Archive artifact digest and size metadata must be additive. Existing `SKILL.md` digest behavior must remain available for compatibility with current skill download and verification APIs.
124126

125127
Current implementation gap:
126128

src/SkillServer/Data/SkillRepository.cs

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ await connection.ExecuteAsync(
9898
"""
9999
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
100100
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
101-
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
101+
size_bytes AS SizeBytes,
102+
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
103+
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
104+
published_at AS PublishedAt, is_latest AS IsLatest
102105
FROM skill_versions
103106
WHERE skill_id = @skillId AND is_latest = 1
104107
""",
@@ -112,7 +115,10 @@ FROM skill_versions
112115
"""
113116
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
114117
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
115-
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
118+
size_bytes AS SizeBytes,
119+
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
120+
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
121+
published_at AS PublishedAt, is_latest AS IsLatest
116122
FROM skill_versions
117123
WHERE skill_id = @skillId AND version = @version
118124
""",
@@ -126,7 +132,10 @@ public async Task<IReadOnlyList<SkillVersion>> GetAllVersionsAsync(long skillId,
126132
"""
127133
SELECT id AS Id, skill_id AS SkillId, version AS Version, description AS Description,
128134
category AS Category, skill_type AS SkillType, sha256 AS Sha256,
129-
size_bytes AS SizeBytes, published_at AS PublishedAt, is_latest AS IsLatest
135+
size_bytes AS SizeBytes,
136+
COALESCE(artifact_sha256, sha256) AS ArtifactSha256,
137+
COALESCE(artifact_size_bytes, size_bytes) AS ArtifactSizeBytes,
138+
published_at AS PublishedAt, is_latest AS IsLatest
130139
FROM skill_versions
131140
WHERE skill_id = @skillId
132141
ORDER BY published_at DESC
@@ -135,14 +144,37 @@ ORDER BY published_at DESC
135144
return versions.ToList();
136145
}
137146

147+
public async Task<IReadOnlyList<SkillVersion>> GetResourcefulVersionsWithoutArchiveAsync(CancellationToken ct = default)
148+
{
149+
await using var connection = new SqliteConnection(_connectionString);
150+
var versions = await connection.QueryAsync<SkillVersion>(
151+
"""
152+
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
153+
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
154+
sv.size_bytes AS SizeBytes,
155+
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
156+
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
157+
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest
158+
FROM skill_versions sv
159+
WHERE sv.skill_type != @archiveType
160+
AND EXISTS (SELECT 1 FROM skill_files sf WHERE sf.skill_version_id = sv.id)
161+
ORDER BY sv.id
162+
""",
163+
new { archiveType = SkillTypes.Archive });
164+
return versions.ToList();
165+
}
166+
138167
public async Task<IReadOnlyList<SkillVersionWithFileCount>> GetAllVersionsWithFileCountAsync(long skillId, CancellationToken ct = default)
139168
{
140169
await using var connection = new SqliteConnection(_connectionString);
141170
var versions = await connection.QueryAsync<SkillVersionWithFileCount>(
142171
"""
143172
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
144173
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
145-
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
174+
sv.size_bytes AS SizeBytes,
175+
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
176+
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
177+
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
146178
(SELECT COUNT(*) FROM skill_files sf WHERE sf.skill_version_id = sv.id) AS FileCount
147179
FROM skill_versions sv
148180
WHERE sv.skill_id = @skillId
@@ -162,7 +194,10 @@ public async Task<IReadOnlyList<SkillVersionWithMetadata>> GetAllLatestVersionsW
162194
var sql = """
163195
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
164196
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
165-
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
197+
sv.size_bytes AS SizeBytes,
198+
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
199+
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
200+
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
166201
s.name AS SkillName, s.created_at AS SkillCreatedAt, s.updated_at AS SkillUpdatedAt,
167202
(SELECT COUNT(*) FROM skill_versions sv2 WHERE sv2.skill_id = s.id) AS VersionCount
168203
FROM skill_versions sv
@@ -196,7 +231,9 @@ public async Task<long> CreateVersionAsync(
196231
string skillType,
197232
string sha256,
198233
long sizeBytes,
199-
CancellationToken ct = default)
234+
CancellationToken ct = default,
235+
string? artifactSha256 = null,
236+
long? artifactSizeBytes = null)
200237
{
201238
await using var connection = new SqliteConnection(_connectionString);
202239
await connection.OpenAsync(ct);
@@ -215,11 +252,23 @@ await connection.ExecuteAsync(
215252

216253
var versionId = await connection.ExecuteScalarAsync<long>(
217254
"""
218-
INSERT INTO skill_versions (skill_id, version, description, category, skill_type, sha256, size_bytes, published_at, is_latest)
219-
VALUES (@skillId, @version, @description, @category, @typeString, @sha256, @sizeBytes, @now, 1);
255+
INSERT INTO skill_versions (skill_id, version, description, category, skill_type, sha256, size_bytes, artifact_sha256, artifact_size_bytes, published_at, is_latest)
256+
VALUES (@skillId, @version, @description, @category, @typeString, @sha256, @sizeBytes, @artifactSha256, @artifactSizeBytes, @now, 1);
220257
SELECT last_insert_rowid();
221258
""",
222-
new { skillId, version, description, category, typeString, sha256, sizeBytes, now },
259+
new
260+
{
261+
skillId,
262+
version,
263+
description,
264+
category,
265+
typeString,
266+
sha256,
267+
sizeBytes,
268+
artifactSha256 = artifactSha256 ?? sha256,
269+
artifactSizeBytes = artifactSizeBytes ?? sizeBytes,
270+
now
271+
},
223272
transaction);
224273

225274
await transaction.CommitAsync(ct);
@@ -232,6 +281,25 @@ INSERT INTO skill_versions (skill_id, version, description, category, skill_type
232281
}
233282
}
234283

284+
public async Task UpdateVersionArtifactAsync(
285+
long versionId,
286+
string skillType,
287+
string artifactSha256,
288+
long artifactSizeBytes,
289+
CancellationToken ct = default)
290+
{
291+
await using var connection = new SqliteConnection(_connectionString);
292+
await connection.ExecuteAsync(
293+
"""
294+
UPDATE skill_versions
295+
SET skill_type = @skillType,
296+
artifact_sha256 = @artifactSha256,
297+
artifact_size_bytes = @artifactSizeBytes
298+
WHERE id = @versionId
299+
""",
300+
new { versionId, skillType, artifactSha256, artifactSizeBytes });
301+
}
302+
235303
public async Task<IReadOnlyList<SkillFile>> GetFilesAsync(long versionId, CancellationToken ct = default)
236304
{
237305
await using var connection = new SqliteConnection(_connectionString);
@@ -275,7 +343,10 @@ public async Task<IReadOnlyList<SkillVersionWithMetadata>> SearchSkillsAsync(
275343
var sql = """
276344
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
277345
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
278-
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
346+
sv.size_bytes AS SizeBytes,
347+
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
348+
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
349+
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
279350
s.name AS SkillName, s.created_at AS SkillCreatedAt, s.updated_at AS SkillUpdatedAt,
280351
(SELECT COUNT(*) FROM skill_versions sv2 WHERE sv2.skill_id = s.id) AS VersionCount
281352
FROM skills_fts
@@ -309,7 +380,10 @@ private static string EscapeFtsToken(string token)
309380
"""
310381
SELECT sv.id AS Id, sv.skill_id AS SkillId, sv.version AS Version, sv.description AS Description,
311382
sv.category AS Category, sv.skill_type AS SkillType, sv.sha256 AS Sha256,
312-
sv.size_bytes AS SizeBytes, sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
383+
sv.size_bytes AS SizeBytes,
384+
COALESCE(sv.artifact_sha256, sv.sha256) AS ArtifactSha256,
385+
COALESCE(sv.artifact_size_bytes, sv.size_bytes) AS ArtifactSizeBytes,
386+
sv.published_at AS PublishedAt, sv.is_latest AS IsLatest,
313387
(SELECT COUNT(*) FROM skill_files sf WHERE sf.skill_version_id = sv.id) AS FileCount
314388
FROM skill_versions sv
315389
JOIN skills s ON s.id = sv.skill_id

src/SkillServer/Endpoints.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ private static void MapSkillEndpoints(this WebApplication app)
4242
skills.MapGet("/{name}/latest", GetLatestVersion);
4343
skills.MapGet("/{name}/{version}", GetVersion);
4444
skills.MapGet("/{name}/{version}/SKILL.md", DownloadSkillMd);
45+
skills.MapGet("/{name}/{version}/archive.zip", DownloadArchive);
4546
skills.MapGet("/{name}/{version}/{*path}", DownloadResource);
4647
skills.MapPost("/check-updates", CheckUpdates);
4748
skills.MapPost("/", UploadSkill).DisableAntiforgery().AddEndpointFilter<ApiKeyEndpointFilter>();
@@ -163,6 +164,28 @@ private static async Task<IResult> DownloadSkillMd(
163164
return Results.File(stream, "text/markdown", "SKILL.md");
164165
}
165166

167+
private static async Task<IResult> DownloadArchive(
168+
string name,
169+
string version,
170+
SkillRepository repository,
171+
BlobStorage blobStorage,
172+
CancellationToken ct)
173+
{
174+
var skill = await repository.GetSkillByNameAsync(name, ct);
175+
if (skill is null)
176+
return Results.NotFound();
177+
178+
var skillVersion = await repository.GetVersionAsync(skill.Id, version, ct);
179+
if (skillVersion is null || skillVersion.SkillType != SkillTypes.Archive)
180+
return Results.NotFound();
181+
182+
var stream = blobStorage.GetBlob(skillVersion.ArtifactSha256);
183+
if (stream is null)
184+
return Results.NotFound();
185+
186+
return Results.File(stream, "application/zip", "archive.zip");
187+
}
188+
166189
private static async Task<IResult> DownloadResource(
167190
string name,
168191
string version,

src/SkillServer/Models/Skill.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public sealed record SkillVersion
2929
public required string SkillType { get; init; }
3030
public required string Sha256 { get; init; }
3131
public required long SizeBytes { get; init; }
32+
public required string ArtifactSha256 { get; init; }
33+
public required long ArtifactSizeBytes { get; init; }
3234
public required DateTimeOffset PublishedAt { get; init; }
3335
public required bool IsLatest { get; init; }
3436
}
@@ -58,6 +60,8 @@ public sealed record SkillVersionWithFileCount
5860
public required string SkillType { get; init; }
5961
public required string Sha256 { get; init; }
6062
public required long SizeBytes { get; init; }
63+
public required string ArtifactSha256 { get; init; }
64+
public required long ArtifactSizeBytes { get; init; }
6165
public required DateTimeOffset PublishedAt { get; init; }
6266
public required bool IsLatest { get; init; }
6367
public required int FileCount { get; init; }
@@ -76,6 +80,8 @@ public sealed record SkillVersionWithMetadata
7680
public required string SkillType { get; init; }
7781
public required string Sha256 { get; init; }
7882
public required long SizeBytes { get; init; }
83+
public required string ArtifactSha256 { get; init; }
84+
public required long ArtifactSizeBytes { get; init; }
7985
public required DateTimeOffset PublishedAt { get; init; }
8086
public required bool IsLatest { get; init; }
8187
public required string SkillName { get; init; }

src/SkillServer/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
builder.Services.AddSingleton<ApiKeyRepository>();
2727
builder.Services.AddSingleton<IndexGenerator>();
2828
builder.Services.AddSingleton<SkillUploadService>();
29+
builder.Services.AddSingleton<SkillArchiveBackfillService>();
2930
builder.Services.AddSingleton<ApiKeyService>();
3031

3132
var app = builder.Build();
@@ -34,6 +35,9 @@
3435
var dbInitializer = app.Services.GetRequiredService<DatabaseInitializer>();
3536
await dbInitializer.InitializeAsync();
3637

38+
var archiveBackfillService = app.Services.GetRequiredService<SkillArchiveBackfillService>();
39+
await archiveBackfillService.BackfillAsync();
40+
3741
// Seed API key from environment variable
3842
var apiKeyService = app.Services.GetRequiredService<ApiKeyService>();
3943
await apiKeyService.SeedFromEnvironmentAsync();

src/SkillServer/Services/IndexGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ public async Task<RfcSkillIndex> GenerateRfcIndexAsync(CancellationToken ct = de
4848
Description = v.Description,
4949
Url = v.SkillType == SkillTypes.SkillMd
5050
? $"{baseUrl}/skills/{v.SkillName}/{v.Version}/SKILL.md"
51-
: $"{baseUrl}/skills/{v.SkillName}/{v.Version}/archive.tar.gz",
52-
Digest = Sha256Digest.Create(v.Sha256).Value,
51+
: $"{baseUrl}/skills/{v.SkillName}/{v.Version}/archive.zip",
52+
Digest = Sha256Digest.Create(v.ArtifactSha256).Value,
5353
Version = v.Version,
5454
Resources = files.Count > 0
5555
? files.Select(f => new RfcResourceEntry

0 commit comments

Comments
 (0)