Serialize typed artifacts at durable boundaries

This commit is contained in:
2026-07-17 07:33:49 +00:00
parent 66de1a5520
commit 814fcdc6ba
23 changed files with 624 additions and 159 deletions

View File

@@ -136,14 +136,14 @@ func logicalFiles(req contracts.OutputRequest) ([]contracts.OutputFile, error) {
usedOutputFiles[name] = output.LaneID
outputIndexes = append(outputIndexes, outputFileIndex{
LaneID: output.LaneID,
MediaType: output.Payload.MediaType,
MediaType: output.Artifact.MediaType,
File: name,
ModuleKey: output.NormalizerKey,
SchemaID: output.Schema.ID,
SchemaName: output.Schema.Name,
SchemaVer: output.Schema.Version,
SchemaID: output.Artifact.Schema.ID,
SchemaName: output.Artifact.Schema.Name,
SchemaVer: output.Artifact.Schema.Version,
})
file, err := rawOutputFile(name, output.Payload)
file, err := serializedOutputFile(name, output.Artifact)
if err != nil {
return nil, err
}
@@ -175,12 +175,12 @@ func logicalFiles(req contracts.OutputRequest) ([]contracts.OutputFile, error) {
return files, nil
}
func rawOutputFile(name string, payload contracts.RawPayload) (contracts.OutputFile, error) {
content := append([]byte(nil), payload.Content...)
func serializedOutputFile(name string, artifact contracts.SerializedArtifact) (contracts.OutputFile, error) {
content := append([]byte(nil), artifact.Content...)
if len(content) == 0 {
content = []byte("null")
}
mediaType := strings.TrimSpace(payload.MediaType)
mediaType := strings.TrimSpace(artifact.MediaType)
if mediaType == "" {
mediaType = "application/octet-stream"
}
@@ -242,14 +242,13 @@ func outputFileName(laneID string) (string, error) {
return "lanes/" + sanitized + ".json", nil
}
func cloneNormalizeOutputs(outputs []contracts.NormalizeOutput) []contracts.NormalizeOutput {
func cloneNormalizeOutputs(outputs []contracts.SerializedOutput) []contracts.SerializedOutput {
if len(outputs) == 0 {
return nil
}
out := make([]contracts.NormalizeOutput, 0, len(outputs))
out := make([]contracts.SerializedOutput, 0, len(outputs))
for _, output := range outputs {
output.Payload = cloneRawPayload(output.Payload)
out = append(out, output)
out = append(out, contracts.CloneSerializedOutput(output))
}
return out
}

View File

@@ -42,7 +42,7 @@ func TestModuleSpecAndRegister(t *testing.T) {
func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
req := contracts.OutputRequest{
Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"},
NormalizeOutputs: []contracts.NormalizeOutput{
NormalizeOutputs: []contracts.SerializedOutput{
normalizeOutput("spells", `{"spell_casts":[{"spell":"Cure Wounds"}]}`),
normalizeOutput("notes/items", `{"items":[{"name":"Torch"}]}`),
},
@@ -234,7 +234,7 @@ func TestEncodeIncludesManifestRawOutputProvenance(t *testing.T) {
func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("///", `{"value":true}`)},
NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("///", `{"value":true}`)},
})
if err == nil {
t.Fatal("Encode() error = nil, want unsafe lane id error")
@@ -246,7 +246,7 @@ func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
func TestEncodeSanitizesParentPathSequences(t *testing.T) {
result, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("dnd..spell.", `{"value":true}`)},
NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("dnd..spell.", `{"value":true}`)},
})
if err != nil {
t.Fatalf("Encode() error = %v, want nil", err)
@@ -260,7 +260,7 @@ func TestEncodeSanitizesParentPathSequences(t *testing.T) {
func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
tests := []struct {
name string
output contracts.NormalizeOutput
output contracts.SerializedOutput
want string
}{
{
@@ -270,9 +270,9 @@ func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
},
{
name: "unsupported media type",
output: func() contracts.NormalizeOutput {
output: func() contracts.SerializedOutput {
output := normalizeOutput("spells", `{"spell_casts":[]}`)
output.Payload.MediaType = "text/plain"
output.Artifact.MediaType = "text/plain"
return output
}(),
want: "unsupported media type",
@@ -282,7 +282,7 @@ func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{test.output},
NormalizeOutputs: []contracts.SerializedOutput{test.output},
})
if err == nil {
t.Fatal("Encode() error = nil, want error")
@@ -296,7 +296,7 @@ func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
_, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{
NormalizeOutputs: []contracts.SerializedOutput{
normalizeOutput("a/b", `{"value":"slash"}`),
normalizeOutput("a?b", `{"value":"question"}`),
},
@@ -312,7 +312,7 @@ func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
func TestEncodeDoesNotMutateInputs(t *testing.T) {
req := contracts.OutputRequest{
Manifest: artifacts.RunManifest{RunID: "run-1"},
NormalizeOutputs: []contracts.NormalizeOutput{
NormalizeOutputs: []contracts.SerializedOutput{
normalizeOutput("spells", `{"name":"original"}`),
},
Rejected: []contracts.RejectedOutput{
@@ -331,8 +331,8 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) {
t.Fatalf("request mutated:\nbefore: %s\nafter: %s", before, after)
}
req.NormalizeOutputs[0].Payload.Content[0] = '['
req.NormalizeOutputs[0].Payload.Metadata["name"] = "changed"
req.NormalizeOutputs[0].Artifact.Content[0] = '['
req.NormalizeOutputs[0].Artifact.Metadata["name"] = "changed"
req.Rejected[0].Message = "changed"
req.Warnings[0].Message = "changed"
@@ -348,7 +348,7 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) {
func TestOutputFilesDoNotContainWarnings(t *testing.T) {
result, err := New().Encode(context.Background(), contracts.OutputRequest{
NormalizeOutputs: []contracts.NormalizeOutput{normalizeOutput("spells", `{"spell":"Shield"}`)},
NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("spells", `{"spell":"Shield"}`)},
Warnings: []contracts.Warning{
{ReasonCode: "pipeline-warning", Message: "warning"},
},
@@ -363,17 +363,16 @@ func TestOutputFilesDoNotContainWarnings(t *testing.T) {
}
}
func normalizeOutput(laneID string, content string) contracts.NormalizeOutput {
return contracts.NormalizeOutput{
func normalizeOutput(laneID string, content string) contracts.SerializedOutput {
return contracts.SerializedOutput{
LaneID: laneID,
NormalizerKey: "noop",
SourceID: "source-1",
Schema: contracts.ResponseSchema{
Artifact: contracts.SerializedArtifact{Schema: contracts.ArtifactSchema{
ID: "schema-id",
Name: "schema-name",
Version: "v1",
},
Payload: contracts.RawPayload{
Content: []byte(content),
MediaType: contentTypeJSON,
Metadata: map[string]any{"name": laneID},