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
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ bundle:
version: "0.4.0-dev"
release_sequence: 0
channel: "development"
source_tree_digest: "sha256:c7c04a600276303a2b4b63ccd2211fcb59eb94bfdcc6320e02eded9c0d2c2b10"
digest: "sha256:e753bacfdd5eedd658dbb63a82f9ce84a3b7efb2b14ae1fd55550fe040a003f5"
source_tree_digest: "sha256:376e0d4a5c11a6214ad70d8ceff8c01645fcf5a71fba332e94e4c2e6061e22a4"
digest: "sha256:681dfce12bea5d4826ba0516c9a1112da41e160abe68d213c27c241e07740821"

projection:
input_digest: "sha256:ba8b4e3da061c68235be603206bcb1042ec9091b4477da9bfbe302273042b4bb"
output_digest: "sha256:8efad1c40b82dda2d01a4ca37756b38bfae9f66cce4ac0f4808b2913d918278b"
input_digest: "sha256:1de3673910b83ab471567d083633ec096f4436e0727d068f37daebbf3d6e9ff5"
output_digest: "sha256:0e9aa2aa74c34b912b8b93e21da1d759ba0552fc55174898f8517d92dc95719c"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:78d09606bb4168d74bce1f50ab62b46a7ded34652c6b23af1badfd26dd060e94"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:a2590f9aaec401c3e313fa639416e8a6b45ddfb96c3e3947a0b0b8c6a6e1dc57"
digest: "sha256:28368b2b4b0e55ac288e9282739caf06b884a6297747c82104dd5a39bada3fce"
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GENERATED FILE - DO NOT EDIT DIRECTLY
# generator: gds
# bundle: 0.4.0-dev
# source-tree-digest: sha256:c7c04a600276303a2b4b63ccd2211fcb59eb94bfdcc6320e02eded9c0d2c2b10
# input-digest: sha256:ba8b4e3da061c68235be603206bcb1042ec9091b4477da9bfbe302273042b4bb
# source-tree-digest: sha256:376e0d4a5c11a6214ad70d8ceff8c01645fcf5a71fba332e94e4c2e6061e22a4
# input-digest: sha256:1de3673910b83ab471567d083633ec096f4436e0727d068f37daebbf3d6e9ff5
# output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4
# edit-source:
# - .gds/repository.yaml
Expand Down
171 changes: 171 additions & 0 deletions core/agentjournal/agentjournal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Package agentjournal records a handoff as an agent-runtime goal.
//
// gds handoff checkpoints unfinished work for a next actor, and that is
// exactly the contract agent-runtime's Goal journal and handoff lifecycle
// events were written for: a durable, revisioned, vendor-neutral record of a
// goal another agent is expected to pick up. The journal lives beside the
// operation state, the lifecycle events append to a JSONL stream, and both
// use agent-runtime's own validation -- GDS adds identity and evidence, never
// a private dialect.
package agentjournal

import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/NDDev-OpenNetwork/agent-runtime/goal"
"github.com/NDDev-OpenNetwork/agent-runtime/observability"
)

const (
// CheckpointItem is the acceptance item the apply completes: the
// checkpoint commit exists and is published.
CheckpointItem = "checkpoint-published"
// VerifiedItem is the acceptance item the verify completes: the handoff
// operation re-proved the checkpoint.
VerifiedItem = "handoff-verified"
)

var invalidIDRunes = regexp.MustCompile(`[^a-z0-9._-]+`)

// Recorder writes goal journals and lifecycle events under one directory.
type Recorder struct {
Directory string
Now func() time.Time
}

func (r Recorder) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now().UTC()
}

// GoalID derives a valid agent-runtime goal id from an operation id.
func GoalID(operationID string) string {
lowered := strings.ToLower(strings.TrimSpace(operationID))
lowered = strings.ReplaceAll(lowered, "_", "-")
lowered = invalidIDRunes.ReplaceAllString(lowered, "-")
lowered = strings.Trim(lowered, "._-")
if lowered == "" {
lowered = "operation"
}
return "handoff." + lowered
}

// JournalPath is where an operation's goal journal lives.
func (r Recorder) JournalPath(operationID string) string {
return filepath.Join(r.Directory, GoalID(operationID)+".json")
}

func (r Recorder) eventsPath() string {
return filepath.Join(r.Directory, "handoff-events.jsonl")
}

// RecordCheckpoint creates the goal journal for an applied handoff and marks
// the checkpoint item complete with its commit evidence, then appends the
// dispatched handoff event.
func (r Recorder) RecordCheckpoint(
ctx context.Context,
operationID, repositoryID, intent string,
files []string,
commitReference string,
sessionID string,
) error {
if err := os.MkdirAll(r.Directory, 0o700); err != nil {
return fmt.Errorf("create agent journal directory: %w", err)
}
now := r.now()
journal, err := goal.New(GoalID(operationID), intent, []goal.ChecklistItem{
{ID: CheckpointItem, Acceptance: "the checkpoint commit exists on the published branch"},
{ID: VerifiedItem, Acceptance: "gds handoff --verify re-proved the checkpoint"},
}, []string{"integration", "cleanup"}, now)
if err != nil {
return fmt.Errorf("draft handoff goal: %w", err)
}
evidence := []goal.Evidence{{Type: goal.EvidenceCommit, Reference: commitReference, Result: "checkpoint published for " + repositoryID}}
for _, file := range files {
evidence = append(evidence, goal.Evidence{Type: goal.EvidenceFile, Reference: file, Result: "carried by the checkpoint"})
}
store := goal.Store{Path: r.JournalPath(operationID)}
// The store insists on a genesis create followed by a CAS update -- the
// same discipline every other consumer gets, so GDS takes it too.
if err := store.Create(journal); err != nil {
return fmt.Errorf("store handoff goal: %w", err)
}
if _, err := store.Update(journal.Revision, func(stored *goal.Journal) error {
return stored.CompleteItem(CheckpointItem, evidence, now)
}); err != nil {
return fmt.Errorf("complete checkpoint item: %w", err)
}
return r.emit(ctx, operationID, sessionID, observability.HandoffStageDispatched)
}

// RecordVerified marks the verification item complete on the stored journal
// and appends the completed handoff event.
func (r Recorder) RecordVerified(
ctx context.Context,
operationID string,
verificationReference string,
sessionID string,
) error {
store := goal.Store{Path: r.JournalPath(operationID)}
current, err := store.Load()
if err != nil {
return fmt.Errorf("load handoff goal: %w", err)
}
// A re-verify is idempotent: the journal already says it, and the event
// stream already carries it, so neither is repeated.
for _, item := range current.Goal.Acceptance {
if item.ID == VerifiedItem && item.Status == goal.ItemComplete {
return nil
}
}
now := r.now()
if _, err := store.Update(current.Revision, func(journal *goal.Journal) error {
return journal.CompleteItem(VerifiedItem, []goal.Evidence{{
Type: goal.EvidenceCommand, Reference: verificationReference,
Result: "handoff verify succeeded",
}}, now)
}); err != nil {
return fmt.Errorf("complete verification item: %w", err)
}
return r.emit(ctx, operationID, sessionID, observability.HandoffStageCompleted)
}

func (r Recorder) emit(ctx context.Context, operationID, sessionID string, stage observability.HandoffStage) error {
sink, err := observability.OpenJSONLSink(r.eventsPath(), observability.JSONLOptions{Name: "gds-handoff"})
if err != nil {
return fmt.Errorf("open handoff event sink: %w", err)
}
defer sink.Close(ctx)
emitter, err := observability.NewEmitter(
observability.Runtime{ID: "gds", Version: "handoff-v1"},
[]observability.Sink{sink},
observability.Options{Clock: r.now},
)
if err != nil {
return fmt.Errorf("build handoff event emitter: %w", err)
}
draft, err := observability.HandoffDraft(
GoalID(operationID),
observability.ActorWorker, observability.ActorWorker, stage, nil, nil,
observability.Context{
CorrelationID: GoalID(operationID),
Actor: observability.Actor{Kind: observability.ActorWorker, ID: sessionID},
Attempt: observability.AttemptInitial,
},
)
if err != nil {
return fmt.Errorf("draft handoff event: %w", err)
}
if _, _, err := emitter.Emit(ctx, draft); err != nil {
return fmt.Errorf("emit handoff event: %w", err)
}
return nil
}
94 changes: 94 additions & 0 deletions core/agentjournal/agentjournal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package agentjournal

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/NDDev-OpenNetwork/agent-runtime/goal"
)

func fixedNow() time.Time { return time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) }

func TestGoalIDDerivesAValidRuntimeIdentity(t *testing.T) {
id := GoalID("op_01M0G5WWVAHQGK33XJ9M183MJ5")
if id != "handoff.op-01m0g5wwvahqgk33xj9m183mj5" {
t.Fatalf("id=%q", id)
}
if _, err := goal.New(id, "x", []goal.ChecklistItem{{ID: "a", Acceptance: "b"}}, nil, fixedNow()); err != nil {
t.Fatalf("derived id refused by agent-runtime: %v", err)
}
}

func TestRecordCheckpointThenVerifiedCompletesTheGoalStory(t *testing.T) {
recorder := Recorder{Directory: filepath.Join(t.TempDir(), "agent-journals"), Now: fixedNow}
ctx := context.Background()
err := recorder.RecordCheckpoint(ctx, "op_TEST123", "device:example/repo", "checkpoint the refactor", []string{"a.go", "b.go"}, "operation:op_TEST123", "session-1")
if err != nil {
t.Fatal(err)
}
store := goal.Store{Path: recorder.JournalPath("op_TEST123")}
journal, err := store.Load()
if err != nil {
t.Fatal(err)
}
if err := journal.Validate(); err != nil {
t.Fatalf("stored journal does not validate under agent-runtime: %v", err)
}
byID := map[string]goal.ChecklistItem{}
for _, item := range journal.Goal.Acceptance {
byID[item.ID] = item
}
if byID[CheckpointItem].Status != goal.ItemComplete {
t.Fatal("checkpoint item is not complete after apply")
}
if byID[VerifiedItem].Status != goal.ItemPending {
t.Fatal("verification item must stay pending until verify")
}
// Files ride as evidence on the completed item.
if len(byID[CheckpointItem].Evidence) != 3 {
t.Fatalf("evidence=%+v", byID[CheckpointItem].Evidence)
}

if err := recorder.RecordVerified(ctx, "op_TEST123", "gds handoff --verify op_TEST123", "session-2"); err != nil {
t.Fatal(err)
}
journal, err = store.Load()
if err != nil {
t.Fatal(err)
}
for _, item := range journal.Goal.Acceptance {
if item.ID == VerifiedItem && item.Status != goal.ItemComplete {
t.Fatal("verification item is not complete after verify")
}
}
// A second verify is idempotent, not a corruption.
if err := recorder.RecordVerified(ctx, "op_TEST123", "gds handoff --verify op_TEST123", "session-2"); err != nil {
t.Fatalf("re-verify must not fail: %v", err)
}

// The lifecycle stream carries the dispatched and completed handoff events.
raw, err := os.ReadFile(filepath.Join(recorder.Directory, "handoff-events.jsonl"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) < 2 {
t.Fatalf("events=%d, want at least dispatched and completed", len(lines))
}
var first map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("event stream is not JSONL: %v", err)
}
}

func TestRecordVerifiedWithoutACheckpointRefuses(t *testing.T) {
recorder := Recorder{Directory: t.TempDir(), Now: fixedNow}
if err := recorder.RecordVerified(context.Background(), "op_NOPE", "ref", "s"); err == nil {
t.Fatal("verify without a stored goal was accepted")
}
}
48 changes: 46 additions & 2 deletions core/app/handoff_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"github.com/NDDev-OpenNetwork/github-device-sync/core/agentjournal"
"github.com/NDDev-OpenNetwork/github-device-sync/core/canonicaljson"
"github.com/NDDev-OpenNetwork/github-device-sync/core/compiler"
"github.com/NDDev-OpenNetwork/github-device-sync/core/domain"
Expand Down Expand Up @@ -205,7 +206,7 @@ func (services *Services) ApplyHandoff(
if finding := validateOperationActor(options.DeviceID, options.SessionID); finding != nil {
return domain.NewEnvelope("gds handoff apply", domain.ExitInput, nil, *finding)
}
_, store, stateFinding := openOperationState(ctx, options.StatePath)
statePath, store, stateFinding := openOperationState(ctx, options.StatePath)
if stateFinding != nil {
return domain.NewEnvelope("gds handoff apply", domain.ExitInput, nil, *stateFinding)
}
Expand Down Expand Up @@ -251,9 +252,40 @@ func (services *Services) ApplyHandoff(
envelope.Mutation.Attempted = result.MutationAttempted
envelope.Mutation.Completed = result.MutationCompleted
envelope.Scope["repository_id"] = plan.Scope.Repositories[0]
// The checkpoint is durable; now the handoff itself becomes a durable
// goal another agent can pick up. The journal is evidence beside the
// operation, so a failure to write it is a finding, never a rollback of
// a checkpoint that already exists.
recorder := agentjournal.Recorder{
Directory: filepath.Join(filepath.Dir(statePath), "agent-journals"),
Now: services.Now,
}
if err := recorder.RecordCheckpoint(
ctx, result.OperationID, plan.Scope.Repositories[0],
handoffGoalIntent(plan), files, "operation:"+result.OperationID,
options.SessionID,
); err != nil {
envelope.Findings = append(envelope.Findings, domain.Finding{
Code: "GDS_HANDOFF_AGENT_JOURNAL_NOT_RECORDED", Severity: domain.SeverityMedium,
Message: "The checkpoint exists, but its agent-runtime goal journal was not recorded: " + err.Error(),
})
} else {
envelope.Scope["agent_journal"] = recorder.JournalPath(result.OperationID)
}
return envelope
}

// handoffGoalIntent names the goal after the plan's own checkpoint message,
// falling back to a stable phrase when the plan carries none.
func handoffGoalIntent(plan operations.Plan) string {
for _, step := range plan.Steps {
if message, ok := step.Parameters["message"].(string); ok && strings.TrimSpace(message) != "" {
return message
}
}
return "carry the checkpointed work to completion"
}

func (services *Services) VerifyHandoff(
ctx context.Context,
operationID string,
Expand All @@ -265,7 +297,7 @@ func (services *Services) VerifyHandoff(
if finding := validateOperationActor(options.DeviceID, options.SessionID); finding != nil {
return domain.NewEnvelope("gds handoff verify", domain.ExitInput, nil, *finding)
}
_, store, stateFinding := openOperationState(ctx, options.StatePath)
statePath, store, stateFinding := openOperationState(ctx, options.StatePath)
if stateFinding != nil {
return domain.NewEnvelope("gds handoff verify", domain.ExitInput, nil, *stateFinding)
}
Expand Down Expand Up @@ -301,6 +333,18 @@ func (services *Services) VerifyHandoff(
envelope := domain.Success("gds handoff verify", result)
envelope.OperationID = operationID
envelope.Scope["repository_id"] = plan.Scope.Repositories[0]
recorder := agentjournal.Recorder{
Directory: filepath.Join(filepath.Dir(statePath), "agent-journals"),
Now: services.Now,
}
if err := recorder.RecordVerified(ctx, operationID, "gds handoff --verify "+operationID, options.SessionID); err != nil {
envelope.Findings = append(envelope.Findings, domain.Finding{
Code: "GDS_HANDOFF_AGENT_JOURNAL_NOT_RECORDED", Severity: domain.SeverityMedium,
Message: "The verification succeeded, but the agent-runtime goal journal was not advanced: " + err.Error(),
})
} else {
envelope.Scope["agent_journal"] = recorder.JournalPath(operationID)
}
return envelope
}

Expand Down
Loading