-
Notifications
You must be signed in to change notification settings - Fork 381
Expand file tree
/
Copy pathlifecycle.go
More file actions
246 lines (218 loc) · 8.29 KB
/
Copy pathlifecycle.go
File metadata and controls
246 lines (218 loc) · 8.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
package opencode
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/validation"
)
var runOpenCodeExportToFileFn = runOpenCodeExportToFile
// Compile-time assertion that OpenCode can inject context into the model.
var _ agent.ContextInjector = (*OpenCodeAgent)(nil)
// InjectionEvent reports that OpenCode injects model context at TurnStart. The
// embedded plugin reads the turn-start hook's stdout and applies the injection
// via experimental.chat.system.transform.
func (a *OpenCodeAgent) InjectionEvent() agent.EventType { return agent.TurnStart }
// RenderContextInjection emits a {"inject_context":"..."} envelope on stdout for
// the plugin to apply. Returns (nil, nil) for empty text.
func (a *OpenCodeAgent) RenderContextInjection(inj agent.ContextInjection) ([]byte, error) {
if strings.TrimSpace(inj.Text) == "" {
return nil, nil
}
b, err := json.Marshal(struct {
InjectContext string `json:"inject_context"`
}{InjectContext: inj.Text})
if err != nil {
return nil, fmt.Errorf("marshal opencode context injection: %w", err)
}
return append(b, '\n'), nil
}
// Hook name constants — these become CLI subcommands under `entire hooks opencode`.
const (
HookNameSessionStart = "session-start"
HookNameSessionEnd = "session-end"
HookNameTurnStart = "turn-start"
HookNameTurnEnd = "turn-end"
HookNameCompaction = "compaction"
)
// HookNames returns the hook verbs this agent supports.
func (a *OpenCodeAgent) HookNames() []string {
return []string{
HookNameSessionStart,
HookNameSessionEnd,
HookNameTurnStart,
HookNameTurnEnd,
HookNameCompaction,
}
}
// ParseHookEvent translates OpenCode hook calls into normalized lifecycle events.
func (a *OpenCodeAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
switch hookName {
case HookNameSessionStart:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionStart,
SessionID: raw.SessionID,
Timestamp: time.Now(),
}, nil
case HookNameTurnStart:
raw, err := agent.ReadAndParseHookInput[turnStartRaw](stdin)
if err != nil {
return nil, err
}
transcriptPath, err := sessionTranscriptPath(ctx, raw.SessionID)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnStart,
SessionID: raw.SessionID,
SessionRef: transcriptPath,
Prompt: raw.Prompt,
Model: raw.Model,
Timestamp: time.Now(),
}, nil
case HookNameTurnEnd:
raw, err := agent.ReadAndParseHookInput[turnEndRaw](stdin)
if err != nil {
return nil, err
}
// Export is deferred to PrepareTranscript; we just compute the path here.
transcriptPath, err := sessionTranscriptPath(ctx, raw.SessionID)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: transcriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil
case HookNameCompaction:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.Compaction,
SessionID: raw.SessionID,
Timestamp: time.Now(),
}, nil
case HookNameSessionEnd:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionEnd,
SessionID: raw.SessionID,
Timestamp: time.Now(),
}, nil
default:
return nil, nil //nolint:nilnil // nil event = no lifecycle action for unknown hooks
}
}
// PrepareTranscript ensures the OpenCode transcript file is up-to-date by calling `opencode export`.
// OpenCode's transcript is created/updated via `opencode export`, but condensation may need fresh
// data mid-turn (e.g., during mid-turn commits or resumed sessions where the cached file is stale).
// This method always refreshes the transcript to ensure the latest agent activity is captured.
func (a *OpenCodeAgent) PrepareTranscript(ctx context.Context, sessionRef string) error {
// Validate the session ref path
if _, err := os.Stat(sessionRef); err != nil && !os.IsNotExist(err) {
// Permission denied, broken symlink, or other non-recoverable errors
return fmt.Errorf("failed to stat OpenCode transcript path %s: %w", sessionRef, err)
}
// Extract session ID from path: basename without .json extension
base := filepath.Base(sessionRef)
if !strings.HasSuffix(base, ".json") {
return fmt.Errorf("invalid OpenCode transcript path (expected .json): %s", sessionRef)
}
sessionID := strings.TrimSuffix(base, ".json")
if sessionID == "" {
return fmt.Errorf("empty session ID in transcript path: %s", sessionRef)
}
// Always call fetchAndCacheExport to get fresh transcript data.
// This is critical for resumed sessions where the cached file may contain stale data
// from a previous turn. Unlike turn-end (which always runs export), mid-turn commits
// need to refresh the transcript to capture agent activity since the last export.
_, err := a.fetchAndCacheExport(ctx, sessionID)
return err
}
// FetchTranscript materializes the session's transcript via `opencode export`
// and returns the cached path. Unlike PrepareTranscript (which only refreshes
// an existing file), this works for sessions Entire never tracked — e.g.
// sessions spawned by an external host, where no hook ever cached an export.
func (a *OpenCodeAgent) FetchTranscript(ctx context.Context, sessionID string) (string, error) {
return a.fetchAndCacheExport(ctx, sessionID)
}
// sessionTranscriptPath validates the session ID and returns the expected transcript path.
func sessionTranscriptPath(ctx context.Context, sessionID string) (string, error) {
if err := validation.ValidateSessionID(sessionID); err != nil {
return "", fmt.Errorf("invalid session ID for transcript path: %w", err)
}
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "."
}
return filepath.Join(repoRoot, paths.EntireTmpDir, sessionID+".json"), nil
}
// fetchAndCacheExport calls `opencode export <sessionID>` and writes the result
// to a temporary file. Returns the path to the temp file.
//
// Integration testing: Set ENTIRE_TEST_OPENCODE_MOCK_EXPORT=1 to skip the
// `opencode export` call and use pre-written mock data instead. Tests must
// pre-write the transcript file to .entire/tmp/<sessionID>.json before
// triggering the hook. See integration_test/hooks.go:SimulateOpenCodeTurnEnd.
func (a *OpenCodeAgent) fetchAndCacheExport(ctx context.Context, sessionID string) (string, error) {
if err := validation.ValidateSessionID(sessionID); err != nil {
return "", fmt.Errorf("invalid session ID for export: %w", err)
}
// Get worktree root for the temp directory
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "."
}
tmpDir := filepath.Join(repoRoot, paths.EntireTmpDir)
tmpFile := filepath.Join(tmpDir, sessionID+".json")
// Integration test mode: use pre-written mock file without calling opencode export
if os.Getenv("ENTIRE_TEST_OPENCODE_MOCK_EXPORT") != "" {
if _, err := os.Stat(tmpFile); err == nil {
return tmpFile, nil
}
return "", fmt.Errorf("mock export file not found: %s (ENTIRE_TEST_OPENCODE_MOCK_EXPORT is set)", tmpFile)
}
// Write export directly to temp file under .entire. Avoid stdout capture,
// which can truncate large payloads in some opencode versions.
if err := os.MkdirAll(tmpDir, 0o750); err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}
if err := runOpenCodeExportToFileFn(ctx, sessionID, tmpFile); err != nil {
return "", fmt.Errorf("opencode export failed: %w", err)
}
//nolint:gosec // tmpFile is constructed from validated session ID under repo .entire/tmp
data, err := os.ReadFile(tmpFile)
if err != nil {
return "", fmt.Errorf("failed to read export file: %w", err)
}
if !json.Valid(data) {
logging.Debug(logging.WithComponent(ctx, "lifecycle"),
"opencode export file contained invalid JSON",
slog.Int("bytes", len(data)),
slog.String("path", tmpFile),
)
return "", fmt.Errorf("opencode export returned invalid JSON (%d bytes)", len(data))
}
return tmpFile, nil
}