@sven1103/opencode-worktree-workflow 0.6.3 → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const STORE_VERSION = 1;
7
+
8
+ const LIFECYCLE_VALUES = new Set(["active", "inactive", "completed", "blocked"]);
9
+ const WORKSPACE_ROLES = new Set(["linear-flow", "planner", "implementer", "reviewer"]);
10
+ const CREATED_BY_VALUES = new Set(["manual", "harness"]);
11
+
12
+ function normalizeLifecycleStatus(value) {
13
+ if (value === "cleaned") return "completed";
14
+ return LIFECYCLE_VALUES.has(value) ? value : "inactive";
15
+ }
16
+
17
+ function normalizeOptionalString(value) {
18
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
19
+ }
20
+
21
+ function normalizeWorkspaceRole(value) {
22
+ return WORKSPACE_ROLES.has(value) ? value : "linear-flow";
23
+ }
24
+
25
+ function normalizeCreatedBy(value, fallback = "manual") {
26
+ if (CREATED_BY_VALUES.has(value)) return value;
27
+ return CREATED_BY_VALUES.has(fallback) ? fallback : "manual";
28
+ }
29
+
30
+ function createDefaultState(repoRoot, sessionID) {
31
+ return {
32
+ schema_version: STORE_VERSION,
33
+ repo_root: path.resolve(repoRoot),
34
+ session_id: sessionID,
35
+ active_task_id: null,
36
+ tasks: [],
37
+ };
38
+ }
39
+
40
+ function findTaskIndex(tasks, taskPatch) {
41
+ if (taskPatch?.task_id) {
42
+ const byID = tasks.findIndex((task) => task?.task_id === taskPatch.task_id);
43
+ if (byID !== -1) return byID;
44
+ }
45
+
46
+ return tasks.findIndex((task) => {
47
+ if (taskPatch?.branch && task?.branch === taskPatch.branch) return true;
48
+ if (taskPatch?.worktree_path && task?.worktree_path === taskPatch.worktree_path) return true;
49
+ return false;
50
+ });
51
+ }
52
+
53
+ function normalizeLoadedState(parsed, repoRoot, sessionID) {
54
+ if (!parsed || typeof parsed !== "object") return createDefaultState(repoRoot, sessionID);
55
+
56
+ const state = createDefaultState(repoRoot, sessionID);
57
+ const incomingTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
58
+ const legacyActiveTask = typeof parsed.active_task === "string" && parsed.active_task ? parsed.active_task : null;
59
+ const declaredActiveTask = typeof parsed.active_task_id === "string" && parsed.active_task_id ? parsed.active_task_id : null;
60
+
61
+ state.tasks = incomingTasks.map((task, index) => {
62
+ const normalized = {
63
+ ...(task && typeof task === "object" ? task : {}),
64
+ task_id: task?.task_id || task?.branch || task?.worktree_path || `task-${index + 1}`,
65
+ status: normalizeLifecycleStatus(task?.status),
66
+ title: normalizeOptionalString(task?.title),
67
+ workspace_role: normalizeWorkspaceRole(task?.workspace_role),
68
+ created_by: normalizeCreatedBy(task?.created_by),
69
+ };
70
+ return normalized;
71
+ });
72
+
73
+ let activeTaskID = declaredActiveTask;
74
+ if (!activeTaskID && legacyActiveTask) {
75
+ const legacyMatch = state.tasks.find((task) => task.task_id === legacyActiveTask || task.branch === legacyActiveTask);
76
+ activeTaskID = legacyMatch?.task_id ?? null;
77
+ }
78
+ if (!activeTaskID) {
79
+ const activeFromStatus = state.tasks.find((task) => task.status === "active");
80
+ activeTaskID = activeFromStatus?.task_id ?? null;
81
+ }
82
+ if (activeTaskID) {
83
+ const activeTask = state.tasks.find((task) => task.task_id === activeTaskID);
84
+ if (!activeTask || activeTask.status === "completed" || activeTask.status === "blocked") {
85
+ activeTaskID = null;
86
+ }
87
+ }
88
+
89
+ state.active_task_id = activeTaskID;
90
+ if (activeTaskID) {
91
+ state.tasks = state.tasks.map((task) => {
92
+ if (task.status === "completed" || task.status === "blocked") return task;
93
+ return {
94
+ ...task,
95
+ status: task.task_id === activeTaskID ? "active" : "inactive",
96
+ };
97
+ });
98
+ } else {
99
+ state.tasks = state.tasks.map((task) => {
100
+ if (task.status === "active") return { ...task, status: "inactive" };
101
+ return task;
102
+ });
103
+ }
104
+
105
+ return state;
106
+ }
107
+
108
+ function defaultStateDir(env = process.env, platform = process.platform) {
109
+ if (env.OPENCODE_WORKTREE_STATE_DIR) {
110
+ return path.resolve(env.OPENCODE_WORKTREE_STATE_DIR);
111
+ }
112
+
113
+ if (platform === "darwin") {
114
+ return path.join(os.homedir(), "Library", "Application Support", "opencode-worktree-workflow");
115
+ }
116
+
117
+ if (platform === "win32") {
118
+ const appData = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
119
+ return path.join(appData, "opencode-worktree-workflow");
120
+ }
121
+
122
+ const xdgState = env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
123
+ return path.join(xdgState, "opencode-worktree-workflow");
124
+ }
125
+
126
+ function getSessionFilePath(baseDir, repoRoot, sessionID) {
127
+ const key = `${path.resolve(repoRoot)}::${sessionID}`;
128
+ const digest = crypto.createHash("sha256").update(key).digest("hex");
129
+ return path.join(baseDir, "sessions", `${digest}.json`);
130
+ }
131
+
132
+ async function atomicWriteJson(filePath, value) {
133
+ const dir = path.dirname(filePath);
134
+ await fs.mkdir(dir, { recursive: true });
135
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
136
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
137
+ await fs.rename(tempPath, filePath);
138
+ }
139
+
140
+ export function createRuntimeStateStore({ stateDir = defaultStateDir(), now = () => new Date().toISOString() } = {}) {
141
+ async function loadSessionState(repoRoot, sessionID) {
142
+ const filePath = getSessionFilePath(stateDir, repoRoot, sessionID);
143
+
144
+ try {
145
+ const raw = await fs.readFile(filePath, "utf8");
146
+ const parsed = JSON.parse(raw);
147
+ return normalizeLoadedState(parsed, repoRoot, sessionID);
148
+ } catch (error) {
149
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
150
+ return createDefaultState(repoRoot, sessionID);
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ async function saveSessionState(repoRoot, sessionID, state) {
157
+ const filePath = getSessionFilePath(stateDir, repoRoot, sessionID);
158
+ await atomicWriteJson(filePath, state);
159
+ return filePath;
160
+ }
161
+
162
+ async function listRepoSessionStates(repoRoot) {
163
+ const sessionsDir = path.join(stateDir, "sessions");
164
+ const normalizedRepoRoot = path.resolve(repoRoot);
165
+ try {
166
+ const files = await fs.readdir(sessionsDir);
167
+ const states = [];
168
+ for (const fileName of files) {
169
+ if (!fileName.endsWith(".json")) continue;
170
+ const filePath = path.join(sessionsDir, fileName);
171
+ try {
172
+ const raw = await fs.readFile(filePath, "utf8");
173
+ const parsed = JSON.parse(raw);
174
+ const parsedRepoRoot = typeof parsed?.repo_root === "string" ? path.resolve(parsed.repo_root) : null;
175
+ if (parsedRepoRoot !== normalizedRepoRoot) continue;
176
+ const sessionID = typeof parsed?.session_id === "string" && parsed.session_id ? parsed.session_id : fileName.replace(/\.json$/, "");
177
+ states.push(normalizeLoadedState(parsed, normalizedRepoRoot, sessionID));
178
+ } catch {
179
+ continue;
180
+ }
181
+ }
182
+ return states;
183
+ } catch (error) {
184
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return [];
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async function listRepoTasks(repoRoot) {
190
+ const states = await listRepoSessionStates(repoRoot);
191
+ return states.flatMap((state) => {
192
+ const activeTaskID = state?.active_task_id ?? null;
193
+ const tasks = Array.isArray(state?.tasks) ? state.tasks : [];
194
+ return tasks.map((task) => ({ ...task, session_id: state.session_id, active: task?.task_id === activeTaskID }));
195
+ });
196
+ }
197
+
198
+ function getActiveTask(state) {
199
+ return state?.active_task_id ?? null;
200
+ }
201
+
202
+ function findTaskByID(state, taskID) {
203
+ if (!taskID) return null;
204
+ return (Array.isArray(state?.tasks) ? state.tasks : []).find((task) => task?.task_id === taskID) ?? null;
205
+ }
206
+
207
+ function findTaskByWorktreePath(state, worktreePath) {
208
+ if (!worktreePath) return null;
209
+ const resolved = path.resolve(worktreePath);
210
+ return (Array.isArray(state?.tasks) ? state.tasks : []).find((task) => task?.worktree_path && path.resolve(task.worktree_path) === resolved) ?? null;
211
+ }
212
+
213
+ function getActiveTaskRecord(state) {
214
+ return findTaskByID(state, getActiveTask(state));
215
+ }
216
+
217
+ function setActiveTask(state, activeTaskID) {
218
+ const tasks = Array.isArray(state?.tasks) ? state.tasks : [];
219
+ const nextTasks = tasks.map((task) => {
220
+ if (!task || typeof task !== "object") return task;
221
+ if (task.status === "completed" || task.status === "blocked") return task;
222
+ if (!activeTaskID) {
223
+ return task.status === "active" ? { ...task, status: "inactive" } : task;
224
+ }
225
+ return {
226
+ ...task,
227
+ status: task.task_id === activeTaskID ? "active" : "inactive",
228
+ };
229
+ });
230
+
231
+ return {
232
+ ...state,
233
+ active_task_id: activeTaskID,
234
+ tasks: nextTasks,
235
+ };
236
+ }
237
+
238
+ function upsertTask(state, taskPatch) {
239
+ const timestamp = now();
240
+ const tasks = Array.isArray(state?.tasks) ? [...state.tasks] : [];
241
+ const index = findTaskIndex(tasks, taskPatch);
242
+
243
+ if (index === -1) {
244
+ tasks.push({
245
+ ...taskPatch,
246
+ task_id: taskPatch?.task_id || taskPatch?.branch || taskPatch?.worktree_path || `task-${tasks.length + 1}`,
247
+ status: normalizeLifecycleStatus(taskPatch?.status),
248
+ title: normalizeOptionalString(taskPatch?.title),
249
+ workspace_role: normalizeWorkspaceRole(taskPatch?.workspace_role),
250
+ created_by: normalizeCreatedBy(taskPatch?.created_by),
251
+ created_at: timestamp,
252
+ last_used_at: timestamp,
253
+ });
254
+ } else {
255
+ const existing = tasks[index] || {};
256
+ tasks[index] = {
257
+ ...existing,
258
+ ...taskPatch,
259
+ task_id: existing.task_id || taskPatch?.task_id || taskPatch?.branch || taskPatch?.worktree_path || `task-${index + 1}`,
260
+ status: normalizeLifecycleStatus(taskPatch?.status ?? existing.status),
261
+ title: normalizeOptionalString(taskPatch?.title) ?? normalizeOptionalString(existing.title),
262
+ workspace_role: normalizeWorkspaceRole(taskPatch?.workspace_role ?? existing.workspace_role),
263
+ created_by: normalizeCreatedBy(taskPatch?.created_by, existing.created_by),
264
+ created_at: existing.created_at || timestamp,
265
+ last_used_at: timestamp,
266
+ };
267
+ }
268
+
269
+ return {
270
+ ...state,
271
+ tasks,
272
+ };
273
+ }
274
+
275
+ function touchTask(state, taskID) {
276
+ const timestamp = now();
277
+ const tasks = Array.isArray(state?.tasks) ? state.tasks : [];
278
+ return {
279
+ ...state,
280
+ tasks: tasks.map((task) => (task?.task_id === taskID ? { ...task, last_used_at: timestamp } : task)),
281
+ };
282
+ }
283
+
284
+ return {
285
+ stateDir,
286
+ loadSessionState,
287
+ saveSessionState,
288
+ listRepoSessionStates,
289
+ listRepoTasks,
290
+ getActiveTask,
291
+ getActiveTaskRecord,
292
+ findTaskByID,
293
+ findTaskByWorktreePath,
294
+ setActiveTask,
295
+ upsertTask,
296
+ touchTask,
297
+ };
298
+ }
299
+
300
+ export { defaultStateDir };