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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,8 +583,9 @@ transactional forward-only migrations, so CGO is not required.
Logical artifacts are keyed by the stable manifest identity rather than by a
filename. Re-downloading the same selection elsewhere adds another file row;
it does not discard the older path. `library verify` checks regular-file type
and size and updates presence metadata without deleting user data. Hashing is
deliberately opt-in: `library verify --hash` fills or rechecks SHA-256 metadata.
and size and updates presence metadata without deleting user data. Recording
stores SHA-256. `library verify --hash` rechecks it and still fills empty rows
from older libraries.
Playback checkpoints reject far-future timestamps, merge equal-time updates,
and keep completion sticky so out-of-order delivery cannot regress resume.

Expand Down
12 changes: 9 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,15 @@ for every completed lecture download:
canonical binary encoding of the positive institute, subject, session, and
lecture IDs plus the normalized view, quality, and audio choices. Paths,
display text, timestamps, producer version, and file bytes do not affect it.
The optional lowercase `files[].sha256` field is omitted until an explicit
verification pass calculates it. When supplied to the manifest builder, it is
checked against the complete file before being published.
The optional lowercase `files[].sha256` field is the SHA-256 of the completed
file. For newly downloaded lectures, `download --json` emits the manifest the
downloader built, which omits the field; lectures reused by
`--reuse-verified` appear with the manifest read back from the library, which
carries it. The library computes the digest when it records a manifest. When
the manifest builder is given a digest, it checks the complete file before
publishing. Rows recorded by older releases may still have an empty digest.
`library verify --hash` fills any empty row from the bytes on disk; the first
`download --reuse-verified` fills empty rows named in the latest manifest.

A command fails instead of emitting a completed manifest when a selected
playlist cannot be associated with a unique scoped lecture or when any reported
Expand Down
14 changes: 8 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,14 @@ fails before journal mode or tables are changed.
`artifacts` holds immutable logical identity plus the latest manifest;
`artifact_files` retains every distinct materialized path and its presence/hash
state; `playback` holds coalesced resume checkpoints; and `jobs` holds expected
outputs and lifecycle state. Default verification checks type and size; only
`--hash` fills or rechecks SHA-256. `download --reuse-verified` also checks the
container signature and ignores historical `artifact_files` rows that are not
in the latest manifest. Verification updates rows but never deletes media or
history. One-shot CLI downloads record completed manifests best-effort
without creating local job rows. Reuse takes a per-artifact advisory lock under
outputs and lifecycle state. Default verification checks type and size.
Recording a completed manifest stores each file's SHA-256. `--hash` rechecks
that digest and still fills empty rows from older libraries.
`download --reuse-verified` also checks the container signature and ignores
historical `artifact_files` rows that are not in the latest manifest.
Verification updates rows but never deletes media or history. One-shot CLI
downloads record completed manifests best-effort without creating local job
rows. Reuse takes a per-artifact advisory lock under
`$XDG_STATE_HOME/impartus/locks/` for the verify-or-download decision; the
generic watcher keeps its separate `watch.lock` in the same state directory.
The generic watcher creates and starts a local `watch` job before media
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/cli_download_reuse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,43 @@ func TestReuseVerifiedFallsBackAfterTruncation(t *testing.T) {
}
}

func TestReuseVerifiedFallsBackAfterSameSizeHashMismatch(t *testing.T) {
fixture := setupReuseVerifiedFixture(t)
changed := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'm', 'p', '4', '2'}
if err := os.WriteFile(fixture.mediaPath, changed, 0o600); err != nil {
t.Fatal(err)
}

var downloaded client.Lectures
ffmpegCalled := false
deps := reuseVerifiedDeps(t, fixture, func(_ context.Context, _ *config.Config, _ *client.Client, lectures client.Lectures, _ downloadPresentationOptions) (downloadResult, error) {
downloaded = append(client.Lectures(nil), lectures...)
return fallbackDownloadResult(fixture.lecture), nil
})
deps.ensureFFmpeg = func() error {
ffmpegCalled = true
return nil
}
result, err := executeDownloadWithDependenciesContext(
context.Background(),
[]string{"-s", "67", "-S", "8", "--ttid", "42", "--reuse-verified"},
quietDownloadPresentation(),
deps,
)
if err != nil {
t.Fatalf("executeDownloadWithDependenciesContext() error = %v", err)
}
if len(result.Outcomes) != 1 || result.Outcomes[0].Outcome != lectureOutcomeDownloaded || result.Outcomes[0].Reason != string(library.FileHashMismatch) {
t.Fatalf("outcome = %+v", result.Outcomes)
}
if !ffmpegCalled {
t.Fatal("fallback download skipped FFmpeg")
}
if len(downloaded) != 1 || downloaded[0].TTID != fixture.lecture.TTID {
t.Fatalf("downloaded = %+v", downloaded)
}
}

func TestReuseVerifiedReportsVerificationFailedWhenFallbackFails(t *testing.T) {
fixture := setupReuseVerifiedFixture(t)
if err := os.WriteFile(fixture.mediaPath, []byte("short"), 0o600); err != nil {
Expand Down
5 changes: 4 additions & 1 deletion internal/library/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ func (store *Store) RecordManifests(ctx context.Context, manifests []artifact.Ma
if err != nil {
return fmt.Errorf("validate artifact %d: %w", index+1, err)
}
validated[index] = rebuilt
validated[index], err = digestManifestFiles(rebuilt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: A record-time digest failure fails the whole batch and, in --events mode, reports fully published media as a failed job

RecordManifests digests every manifest before BeginTx, so one file that cannot be hashed (swap race in the window after the manifest was built, size drift, or EIO during the first full read) now leaves the entire run unrecorded: applyLibraryRecording (internal/cli/cli_download.go:271-279) sets LibraryRecorded=false for all lectures, and downloadEventStream.finish (internal/cli/cli_download_events.go:47-49) converts that into errDownloadLibraryCommit — a terminal job.failed event and nonzero exit even though all media was published. Before this change, recording never read file bytes, so post-download filesystem state could not fail the commit (only DB errors could). If failing the record is the intended semantics (it prevents persisting a bogus reference digest), consider distinguishing "media published, library commit failed" from a download failure in events mode, or degrading per-manifest so one file does not suppress recording of the other lectures.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if err != nil {
return fmt.Errorf("digest artifact %d: %w", index+1, err)
}
}
if len(validated) == 0 {
return nil
Expand Down
28 changes: 24 additions & 4 deletions internal/library/artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ func TestRecordManifestIsIdempotentAndKeepsEveryMaterializedPath(t *testing.T) {
}
}

func TestRecordManifestPersistsSHA256(t *testing.T) {
store := openTestStore(t)
path := filepath.Join(t.TempDir(), "lecture.mp4")
manifest := buildTestManifest(t, path, "digest")
if err := store.RecordManifest(context.Background(), manifest); err != nil {
t.Fatal(err)
}
record, err := store.GetArtifact(context.Background(), manifest.ArtifactID)
if err != nil {
t.Fatalf("GetArtifact() error = %v", err)
}
const expectedSHA256 = "c43403fe022af967a0b859d3e14ea12d6633f4c8ad475816b0c55d85896e8e35"
if len(record.Files) != 1 || record.Files[0].SHA256 != expectedSHA256 {
t.Fatalf("recorded sha256 = %+v, want %q", record.Files, expectedSHA256)
}
if len(record.Manifest.Files) != 1 || record.Manifest.Files[0].SHA256 != expectedSHA256 {
t.Fatalf("stored manifest files = %+v, want sha256 %q", record.Manifest.Files, expectedSHA256)
}
}

func TestVerifyArtifactRefreshesHashAndMarksMissingWithoutDeleting(t *testing.T) {
store := openTestStore(t)
path := filepath.Join(t.TempDir(), "lecture.mp4")
Expand All @@ -69,7 +89,7 @@ func TestVerifyArtifactRefreshesHashAndMarksMissingWithoutDeleting(t *testing.T)
}
const expectedSHA256 = "c43403fe022af967a0b859d3e14ea12d6633f4c8ad475816b0c55d85896e8e35"
if verified.Files[0].SHA256 != expectedSHA256 {
t.Fatalf("filled sha256 = %q, want %q", verified.Files[0].SHA256, expectedSHA256)
t.Fatalf("recorded sha256 = %q, want %q", verified.Files[0].SHA256, expectedSHA256)
}
changedMedia := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'm', 'p', '4', '2'}
if writeErr := os.WriteFile(path, changedMedia, 0o600); writeErr != nil {
Expand All @@ -82,14 +102,14 @@ func TestVerifyArtifactRefreshesHashAndMarksMissingWithoutDeleting(t *testing.T)
if err != nil {
t.Fatal(err)
}
if len(recordedAgain.Files) != 1 || recordedAgain.Files[0].SHA256 != "" {
t.Fatalf("hashless re-record retained a stale verified hash: %+v", recordedAgain.Files)
wantChangedSHA := fmt.Sprintf("%x", sha256.Sum256(changedMedia))
if len(recordedAgain.Files) != 1 || recordedAgain.Files[0].SHA256 != wantChangedSHA {
t.Fatalf("re-record sha256 = %+v, want %q", recordedAgain.Files, wantChangedSHA)
}
refreshed, err := store.VerifyArtifact(context.Background(), manifest.ArtifactID, library.VerifyOptions{Hash: true})
if err != nil {
t.Fatalf("VerifyArtifact() after replacement error = %v", err)
}
wantChangedSHA := fmt.Sprintf("%x", sha256.Sum256(changedMedia))
if !refreshed.OK || len(refreshed.Files) != 1 || refreshed.Files[0].SHA256 != wantChangedSHA || refreshed.Files[0].SHA256 == expectedSHA256 {
t.Fatalf("replacement verification = %+v, want refreshed hash %q", refreshed, wantChangedSHA)
}
Expand Down
143 changes: 143 additions & 0 deletions internal/library/digest_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package library

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/google/uuid"

"github.com/rabesss/impartus-cli/internal/artifact"
)

func TestDigestManifestFilesRejectsSizeDrift(t *testing.T) {
path := filepath.Join(t.TempDir(), "lecture.mp4")
original := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
manifest, err := (ExpectedArtifact{
Lecture: artifact.Lecture{TTID: 4, InstituteID: 1, SubjectID: 2, SessionID: 3},
Selection: artifact.Selection{Views: "left", Quality: "720"},
Files: []ExpectedFile{{Path: path, Role: "video", View: "left", Container: "mp4"}},
ProducedAt: time.Date(2026, time.August, 9, 8, 0, 0, 0, time.UTC),
Producer: artifact.Producer{Name: "impartus", Version: "test"},
}).buildManifest()
if err != nil {
t.Fatalf("buildManifest() error = %v", err)
}

grown := append(append([]byte(nil), original...), 0)
if err := os.WriteFile(path, grown, 0o600); err != nil {
t.Fatal(err)
}
if _, err := digestManifestFiles(manifest); err == nil {
t.Fatal("digestManifestFiles() error = nil, want size drift rejection")
} else if !strings.Contains(err.Error(), "size changed") {
t.Fatalf("digestManifestFiles() error = %v, want size drift rejection", err)
}
}

func TestDigestManifestFilesKeepsCallerPinnedDigest(t *testing.T) {
path := filepath.Join(t.TempDir(), "lecture.mp4")
media := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}
if err := os.WriteFile(path, media, 0o600); err != nil {
t.Fatal(err)
}
const pinnedSHA256 = "c43403fe022af967a0b859d3e14ea12d6633f4c8ad475816b0c55d85896e8e35"
manifest, err := (ExpectedArtifact{
Lecture: artifact.Lecture{TTID: 4, InstituteID: 1, SubjectID: 2, SessionID: 3},
Selection: artifact.Selection{Views: "left", Quality: "720"},
Files: []ExpectedFile{{Path: path, Role: "video", View: "left", Container: "mp4", SHA256: pinnedSHA256}},
ProducedAt: time.Date(2026, time.August, 9, 8, 0, 0, 0, time.UTC),
Producer: artifact.Producer{Name: "impartus", Version: "test"},
}).buildManifest()
if err != nil {
t.Fatalf("buildManifest() error = %v", err)
}

// A re-hash would invoke the stability proof; fail if the pin is re-read.
oldValidate := validateStableArtifactFile
validateStableArtifactFile = func(string, *os.File, os.FileInfo) error {
return errors.New("pinned digest must pass through without a re-hash")
}
t.Cleanup(func() { validateStableArtifactFile = oldValidate })

digested, err := digestManifestFiles(manifest)
if err != nil {
t.Fatalf("digestManifestFiles() error = %v", err)
}
if len(digested.Files) != 1 || digested.Files[0].SHA256 != pinnedSHA256 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This test cannot detect removal of the pinned-digest skip

artifact.Build already rejects a pinned digest that does not match the file contents, so the only pin that reaches digestManifestFiles is the file's true digest. If digestManifestFiles were changed to re-hash pinned files, the recomputed value would equal pinnedSHA256 and this assertion would still pass — the test verifies the output value, not that the pin was preserved without a second full-file read. To make it meaningful, monkeypatch validateStableArtifactFile to fail (as TestDigestManifestFilesRejectsPathSwapBeforePublication does): the pinned path must return without ever invoking it, so any re-hash fails the test. Note this also means there is currently no effective coverage anywhere that pinned digests pass through unmodified (the same holds for a store-level RecordManifests test).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

t.Fatalf("digestManifestFiles() files = %+v, want pinned %q", digested.Files, pinnedSHA256)
}
}

func TestRecoverInterruptedJobsSkipsJobWhoseOutputsCannotBeDigested(t *testing.T) {
parent := filepath.Join(t.TempDir(), "state")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
store, err := Open(context.Background(), Options{Path: filepath.Join(parent, "library.db")})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := store.Close(); closeErr != nil {
t.Errorf("Close() error = %v", closeErr)
}
})

ctx := context.Background()
media := []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}
unstablePath := filepath.Join(t.TempDir(), "unstable.mp4")
stablePath := filepath.Join(t.TempDir(), "stable.mp4")
for _, path := range []string{unstablePath, stablePath} {
if writeErr := os.WriteFile(path, media, 0o600); writeErr != nil {
t.Fatal(writeErr)
}
}
newJob := func(id, path string, ttid int) {
t.Helper()
expected := ExpectedArtifact{
Lecture: artifact.Lecture{TTID: ttid, InstituteID: 1, SubjectID: 2, SessionID: 3},
Selection: artifact.Selection{Views: "left", Quality: "720"},
Files: []ExpectedFile{{Path: path, Role: "video", View: "left", Container: "mp4"}},
ProducedAt: time.Date(2026, time.August, 9, 10, 0, 0, 0, time.UTC),
Producer: artifact.Producer{Name: "impartus", Version: "test"},
}
if createErr := store.CreateJob(ctx, JobSpec{ID: id, Kind: JobKindDownload, Expected: expected}); createErr != nil {
t.Fatalf("CreateJob(%s) error = %v", id, createErr)
}
if startErr := store.StartJob(ctx, id); startErr != nil {
t.Fatalf("StartJob(%s) error = %v", id, startErr)
}
}
unstableJobID := uuid.NewString()
stableJobID := uuid.NewString()
newJob(unstableJobID, unstablePath, 90)
newJob(stableJobID, stablePath, 91)

oldValidate := validateStableArtifactFile
validateStableArtifactFile = func(path string, file *os.File, initial os.FileInfo) error {
if path == unstablePath {
return errors.New("injected instability")
}
return artifact.ValidateStableCompletedFile(path, file, initial)
}
t.Cleanup(func() { validateStableArtifactFile = oldValidate })

result, err := store.RecoverInterruptedJobs(ctx, JobKindDownload)
if err != nil {
t.Fatalf("RecoverInterruptedJobs() error = %v, want per-job containment of digest failures", err)
}
if len(result.Pending) != 1 || result.Pending[0] != unstableJobID {
t.Fatalf("Pending = %+v, want only the unstable job", result.Pending)
}
if len(result.Recovered) != 1 || result.Recovered[0] != stableJobID {
t.Fatalf("Recovered = %+v, want the stable job to recover past the unstable job", result.Recovered)
}
}
39 changes: 32 additions & 7 deletions internal/library/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ var (
ErrJobTerminal = errors.New("library job is already terminal")
// ErrJobTransition reports a lifecycle operation invalid for the current state.
ErrJobTransition = errors.New("invalid library job transition")
// ErrJobOutputUnstable reports completed outputs that changed or became
// unreadable between validation and the record-time digest.
ErrJobOutputUnstable = errors.New("library job output changed before recording")
)

// ExpectedFile records a final output before media creation starts. Partial
Expand Down Expand Up @@ -225,9 +228,14 @@ func (store *Store) CompleteJob(ctx context.Context, jobID string, manifest arti

// completeValidatedJob atomically records a manifest that was built and
// validated against the current file descriptors by the immediate caller.
// Keeping this separate prevents startup recovery from reopening the same
// paths between validation and the durable transaction.
// Empty digests are filled from a stability-checked read before the write
// transaction begins, so the full-file read never holds the library write
// lock; a job that turns out to be already completed pays that read anyway.
func (store *Store) completeValidatedJob(ctx context.Context, jobID string, validated artifact.Manifest) error {
digested, digestErr := digestManifestFiles(validated)
Comment thread
pullfrog[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: A digest failure during recovery aborts RecoverInterruptedJobs entirely and can wedge watch startup with no per-job escape hatch

completeValidatedJob now performs a stability-checked full-file read before the write transaction. RecoverInterruptedJobs (jobs.go:392-397) skips only ErrJobTerminal/ErrJobTransition/ErrJobNotFound, so any digest error — a path swap or size drift in the window after buildManifest validated the outputs, a mid-read mutation of a multi-GB file, or an EIO on the first full read — returns RecoveryResult{}, err for the whole loop. prepareWatch (internal/cli/cli_watch.go:253-260) then exits the command with watch.ErrDurableState before polling starts: remaining recoverable jobs are not processed this run, and a deterministic cause (e.g. persistent I/O error) makes every subsequent watch start fail identically. Consider treating a digest error like buildErr — record a sanitized summary, park the job as pending/recoverable, and continue to the next job.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if digestErr != nil {
return fmt.Errorf("%w: %w", ErrJobOutputUnstable, digestErr)
}
tx, beginErr := store.database.BeginTx(ctx, nil)
if beginErr != nil {
return fmt.Errorf("begin job completion: %w", beginErr)
Expand All @@ -249,7 +257,7 @@ func (store *Store) completeValidatedJob(ctx context.Context, jobID string, vali
committed = true
return nil
}
if recordErr := recordManifestTx(ctx, tx, validated); recordErr != nil {
if recordErr := recordManifestTx(ctx, tx, digested); recordErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: No test asserts the digest persisted through completeValidatedJob

The acceptance criterion ("recording stores SHA-256") is covered only for the RecordManifests seam (TestRecordManifestPersistsSHA256). TestCompleteJobIdempotencyStillValidatesManifestContract completes a hashless manifest but asserts only the path (jobs_test.go:394), and TestCompleteJobEnforcesExpectedSHA256 exercises only the caller-pinned path. Passing validated instead of digested here would pass the entire suite, so the job-path digest behavior (watcher, API, startup recovery) is effectively untested. Add an assertion that GetArtifact after CompleteJob with a hashless manifest returns the file's SHA-256, mirroring the store-path test.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return recordErr
}
now := formatDatabaseTime(time.Now())
Expand Down Expand Up @@ -385,18 +393,35 @@ func (store *Store) RecoverInterruptedJobs(ctx context.Context, kind string) (Re
continue
}
if err := store.completeValidatedJob(ctx, job.ID, manifest); err != nil {
if errors.Is(err, ErrJobTerminal) || errors.Is(err, ErrJobTransition) || errors.Is(err, ErrJobNotFound) {
result.Skipped = append(result.Skipped, job.ID)
continue
if containErr := store.containRecoveryError(ctx, &result, job.ID, err); containErr != nil {
return RecoveryResult{}, containErr
}
return RecoveryResult{}, err
continue
}
result.Recovered = append(result.Recovered, job.ID)
result.Artifacts = append(result.Artifacts, RecoveredArtifact{JobID: job.ID, Manifest: manifest})
}
return result, nil
}

// containRecoveryError sorts one job's completion failure into the recovery
// result and returns nil when recovery can move on to the next job. Job-state
// failures skip the job; unstable outputs park it recoverable with a summary,
// the same containment the buildErr path above applies, so one unreadable
// output cannot wedge watch startup. Anything else is a durable-store failure
// the caller must abort on.
func (store *Store) containRecoveryError(ctx context.Context, result *RecoveryResult, jobID string, err error) error {
if errors.Is(err, ErrJobTerminal) || errors.Is(err, ErrJobTransition) || errors.Is(err, ErrJobNotFound) {
result.Skipped = append(result.Skipped, jobID)
return nil
}
if errors.Is(err, ErrJobOutputUnstable) {
result.Pending = append(result.Pending, jobID)
return store.setRecoverableSummary(ctx, jobID, "completed outputs changed or became unreadable before recording")
}
return err
}

// Job returns one durable job.
func (store *Store) Job(ctx context.Context, jobID string) (Job, error) {
if store == nil || store.database == nil {
Expand Down
Loading
Loading