Skip to content

Commit 4b9ff0c

Browse files
wesmmaphew
andcommitted
feat(artifact): add origin lifecycle and checkpointed export pipeline
Co-authored-by: maphew <maphew@gmail.com>
1 parent 90c7e48 commit 4b9ff0c

14 files changed

Lines changed: 4290 additions & 0 deletions

internal/artifact/export.go

Lines changed: 923 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
package artifact
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"strconv"
12+
"strings"
13+
14+
"go.kenn.io/agentsview/internal/db"
15+
)
16+
17+
type artifactCheckpointSequenceDB interface {
18+
GetArtifactCheckpointFloor(context.Context, string) (int, bool, error)
19+
ReserveArtifactCheckpointSequence(context.Context, string, int) (int, error)
20+
}
21+
22+
type checkpointFloorStore interface {
23+
checkpointFloor(context.Context, string) (int, error)
24+
}
25+
26+
func openStoreEntryIterator(
27+
ctx context.Context, store ArtifactStore, origin string, kind Kind,
28+
) (EntryIterator, error) {
29+
return store.Entries(ctx, origin, kind)
30+
}
31+
32+
// statRecordedCheckpoint trusts the store's catalog identity, which is
33+
// established by verified immutable creation and checked again on normal
34+
// reads. Periodic unchanged export must remain constant work; full physical
35+
// verification belongs bootstrap and maintenance.
36+
func statRecordedCheckpoint(
37+
ctx context.Context,
38+
store ArtifactStore,
39+
head db.ArtifactCheckpointHead,
40+
) (bool, error) {
41+
ref, err := NewRef(head.Origin, KindCheckpoints,
42+
fmt.Sprintf("cp-%010d.json", head.Sequence))
43+
if err != nil {
44+
return false, err
45+
}
46+
entry, err := store.Stat(ctx, ref)
47+
if errors.Is(err, ErrArtifactNotFound) {
48+
return false, nil
49+
}
50+
if err != nil {
51+
return false, fmt.Errorf("stating recorded artifact checkpoint: %w", err)
52+
}
53+
if entry.Identity.SHA256 != head.CheckpointSHA256 || entry.Identity.Size != head.CheckpointSize {
54+
quarantineErr := store.Quarantine(ctx, ref, "recorded checkpoint identity mismatch")
55+
return false, quarantineErr
56+
}
57+
return true, nil
58+
}
59+
60+
func latestValidCheckpointHead(
61+
ctx context.Context,
62+
store ArtifactStore,
63+
origin string,
64+
) (_ db.ArtifactCheckpointHead, _ bool, retErr error) {
65+
var head db.ArtifactCheckpointHead
66+
iterator, err := openStoreEntryIterator(ctx, store, origin, KindCheckpoints)
67+
if err != nil {
68+
return db.ArtifactCheckpointHead{}, false, fmt.Errorf("listing artifact checkpoints: %w", err)
69+
}
70+
defer func() { retErr = errors.Join(retErr, iterator.Close()) }()
71+
for {
72+
entries, nextErr := iterator.Next(ctx, checkpointFloorPageSize)
73+
if nextErr != nil && !errors.Is(nextErr, io.EOF) {
74+
return db.ArtifactCheckpointHead{}, false, fmt.Errorf("listing artifact checkpoints: %w", nextErr)
75+
}
76+
for _, entry := range entries {
77+
sequence, err := checkpointSequence(entry.Ref.Name)
78+
if err != nil || sequence <= head.Sequence {
79+
continue
80+
}
81+
if entry.Identity.Size > checkpointDecodedLimit {
82+
continue
83+
}
84+
_, reader, err := store.Open(ctx, entry.Ref)
85+
if errors.Is(err, ErrArtifactNotFound) || errors.Is(err, ErrArtifactCorrupt) {
86+
continue
87+
}
88+
if err != nil {
89+
return db.ArtifactCheckpointHead{}, false,
90+
fmt.Errorf("opening artifact checkpoint: %w", err)
91+
}
92+
candidate, decodeErr := decodeCanonicalCheckpointHead(
93+
reader, origin, entry.Ref.Name, entry.Identity,
94+
)
95+
verifyErr := reader.Verify()
96+
closeErr := reader.Close()
97+
if closeErr != nil && !errors.Is(closeErr, ErrArtifactCorrupt) {
98+
return db.ArtifactCheckpointHead{}, false,
99+
fmt.Errorf("closing artifact checkpoint: %w", closeErr)
100+
}
101+
if verifyErr != nil && !errors.Is(verifyErr, ErrArtifactCorrupt) {
102+
return db.ArtifactCheckpointHead{}, false,
103+
fmt.Errorf("verifying artifact checkpoint: %w", verifyErr)
104+
}
105+
if errors.Is(decodeErr, errFutureArtifactVersion) {
106+
return db.ArtifactCheckpointHead{}, false, decodeErr
107+
}
108+
if decodeErr != nil || verifyErr != nil || closeErr != nil {
109+
continue
110+
}
111+
head = candidate
112+
}
113+
if errors.Is(nextErr, io.EOF) {
114+
break
115+
}
116+
}
117+
return head, head.Sequence > 0, nil
118+
}
119+
120+
func decodeCanonicalCheckpointHead(
121+
reader io.Reader,
122+
origin string,
123+
name string,
124+
identity Identity,
125+
) (db.ArtifactCheckpointHead, error) {
126+
decoder := json.NewDecoder(reader)
127+
decoder.UseNumber()
128+
token, err := decoder.Token()
129+
if err != nil || token != json.Delim('{') {
130+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint is not a JSON object")
131+
}
132+
expectedFields := []string{"origin", "seq", "sessions", "v"}
133+
var sequence int
134+
var mapDigest string
135+
for _, expected := range expectedFields {
136+
token, err := decoder.Token()
137+
if err != nil {
138+
return db.ArtifactCheckpointHead{}, err
139+
}
140+
field, ok := token.(string)
141+
if !ok || field != expected {
142+
return db.ArtifactCheckpointHead{}, fmt.Errorf(
143+
"checkpoint is not canonical: expected field %q", expected,
144+
)
145+
}
146+
switch field {
147+
case "origin":
148+
var got string
149+
if err := decoder.Decode(&got); err != nil {
150+
return db.ArtifactCheckpointHead{}, err
151+
}
152+
if got != origin {
153+
return db.ArtifactCheckpointHead{}, fmt.Errorf(
154+
"checkpoint origin mismatch for %s: got %q", origin, got,
155+
)
156+
}
157+
case "seq":
158+
var number json.Number
159+
if err := decoder.Decode(&number); err != nil {
160+
return db.ArtifactCheckpointHead{}, err
161+
}
162+
value, err := strconv.ParseInt(number.String(), 10, 32)
163+
if err != nil || value < 1 {
164+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint sequence is invalid")
165+
}
166+
sequence = int(value)
167+
case "sessions":
168+
mapDigest, err = decodeCanonicalCheckpointSessionMap(decoder, origin)
169+
if err != nil {
170+
return db.ArtifactCheckpointHead{}, err
171+
}
172+
case "v":
173+
var number json.Number
174+
if err := decoder.Decode(&number); err != nil {
175+
return db.ArtifactCheckpointHead{}, err
176+
}
177+
version, err := strconv.Atoi(number.String())
178+
if err != nil || version < 1 {
179+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint version is unsupported")
180+
}
181+
if version > formatVersion {
182+
return db.ArtifactCheckpointHead{}, fmt.Errorf(
183+
"%w: checkpoint version %d", errFutureArtifactVersion, version,
184+
)
185+
}
186+
if version != formatVersion {
187+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint version is unsupported")
188+
}
189+
}
190+
}
191+
token, err = decoder.Token()
192+
if err != nil || token != json.Delim('}') {
193+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint object is incomplete")
194+
}
195+
var trailing any
196+
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
197+
if err == nil {
198+
return db.ArtifactCheckpointHead{}, errors.New("checkpoint has trailing JSON")
199+
}
200+
return db.ArtifactCheckpointHead{}, err
201+
}
202+
if fmt.Sprintf("cp-%010d.json", sequence) != name {
203+
return db.ArtifactCheckpointHead{}, fmt.Errorf(
204+
"checkpoint sequence identity mismatch: got %s", name,
205+
)
206+
}
207+
return db.ArtifactCheckpointHead{
208+
Origin: origin, Sequence: sequence,
209+
SessionMapSHA256: mapDigest, CheckpointSHA256: identity.SHA256,
210+
CheckpointSize: identity.Size,
211+
}, nil
212+
}
213+
214+
func decodeCanonicalCheckpointSessionMap(
215+
decoder *json.Decoder,
216+
origin string,
217+
) (string, error) {
218+
token, err := decoder.Token()
219+
if err != nil || token != json.Delim('{') {
220+
return "", errors.New("checkpoint sessions is not an object")
221+
}
222+
hasher := sha256.New()
223+
_, _ = io.WriteString(hasher, "{")
224+
first := true
225+
previous := ""
226+
for decoder.More() {
227+
token, err := decoder.Token()
228+
if err != nil {
229+
return "", err
230+
}
231+
gid, ok := token.(string)
232+
if !ok || gid == "" || !strings.HasPrefix(gid, origin+"~") {
233+
return "", errors.New("checkpoint session identity is invalid")
234+
}
235+
if !first && gid <= previous {
236+
return "", errors.New("checkpoint sessions are not in canonical order")
237+
}
238+
var manifestHash string
239+
if err := decoder.Decode(&manifestHash); err != nil {
240+
return "", err
241+
}
242+
if err := validateHashHex(manifestHash); err != nil {
243+
return "", fmt.Errorf("checkpoint manifest hash is invalid: %w", err)
244+
}
245+
if !first {
246+
_, _ = io.WriteString(hasher, ",")
247+
}
248+
gidJSON, _ := json.Marshal(gid)
249+
hashJSON, _ := json.Marshal(manifestHash)
250+
_, _ = hasher.Write(gidJSON)
251+
_, _ = io.WriteString(hasher, ":")
252+
_, _ = hasher.Write(hashJSON)
253+
first = false
254+
previous = gid
255+
}
256+
token, err = decoder.Token()
257+
if err != nil || token != json.Delim('}') {
258+
return "", errors.New("checkpoint sessions object is incomplete")
259+
}
260+
_, _ = io.WriteString(hasher, "}\n")
261+
return hex.EncodeToString(hasher.Sum(nil)), nil
262+
}
263+
264+
// Export temporarily preserves the root-based API while canonical publication
265+
// migrates to ArtifactStore. The reference filesystem store is isolated from
266+
// the legacy wire tree, then encoded into that tree for existing transports.
267+
func reserveCheckpointSequenceFromStore(
268+
ctx context.Context,
269+
database artifactCheckpointSequenceDB,
270+
store ArtifactStore,
271+
origin string,
272+
) (_ int, retErr error) {
273+
_, bootstrapped, err := database.GetArtifactCheckpointFloor(ctx, origin)
274+
if err != nil {
275+
return 0, fmt.Errorf("reading checkpoint floor for %s: %w", origin, err)
276+
}
277+
if bootstrapped {
278+
sequence, err := database.ReserveArtifactCheckpointSequence(ctx, origin, 0)
279+
if err != nil {
280+
return 0, fmt.Errorf("reserving checkpoint sequence for %s: %w", origin, err)
281+
}
282+
return sequence, nil
283+
}
284+
observedFloor := 0
285+
if observer, ok := store.(checkpointFloorStore); ok {
286+
floor, err := observer.checkpointFloor(ctx, origin)
287+
if err != nil {
288+
return 0, fmt.Errorf("listing checkpoint floor for %s: %w", origin, err)
289+
}
290+
observedFloor = floor
291+
} else {
292+
iterator, err := openStoreEntryIterator(ctx, store, origin, KindCheckpoints)
293+
if err != nil {
294+
return 0, fmt.Errorf("listing checkpoint floor for %s: %w", origin, err)
295+
}
296+
defer func() { retErr = errors.Join(retErr, iterator.Close()) }()
297+
for {
298+
entries, nextErr := iterator.Next(ctx, checkpointFloorPageSize)
299+
if nextErr != nil && !errors.Is(nextErr, io.EOF) {
300+
return 0, fmt.Errorf("listing checkpoint floor for %s: %w", origin, nextErr)
301+
}
302+
for _, entry := range entries {
303+
sequence, err := checkpointSequence(entry.Ref.Name)
304+
if err != nil {
305+
continue
306+
}
307+
observedFloor = max(observedFloor, sequence)
308+
}
309+
if errors.Is(nextErr, io.EOF) {
310+
break
311+
}
312+
}
313+
}
314+
sequence, err := database.ReserveArtifactCheckpointSequence(ctx, origin, observedFloor)
315+
if err != nil {
316+
return 0, fmt.Errorf("reserving checkpoint sequence for %s: %w", origin, err)
317+
}
318+
return sequence, nil
319+
}
320+
321+
func normalizeManifestSessionLocalState(sess *manifestSession) {
322+
// Keep non-content, machine-local state out of the canonical manifest so a
323+
// source-only change to it does not alter the content hash and trigger a
324+
// re-import that clears the importer's local findings. secret_leak_count is
325+
// import-discarded secret state (see rewriteForImport); local_modified_at is
326+
// the local sync watermark, which import ignores (the importer stamps its
327+
// own) -- and a secret rescan bumps both even when no exported message
328+
// content changed. The file_* fields are source-file bookkeeping that
329+
// import clears (see clearImportedSessionSourceState); a touch, move, or
330+
// re-download of the source file changes them without changing any
331+
// exported content.
332+
sess.SecretLeakCount = 0
333+
sess.LocalModifiedAt = nil
334+
sess.FilePath = nil
335+
sess.FileSize = nil
336+
sess.FileMtime = nil
337+
sess.FileInode = nil
338+
sess.FileDevice = nil
339+
sess.FileHash = nil
340+
}

0 commit comments

Comments
 (0)