@applica-software-guru/sdd-core 1.4.1 → 1.5.0

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.
@@ -1,15 +1,30 @@
1
1
  import type { SDDConfig } from '../types.js';
2
- import { RemoteError, RemoteNotConfiguredError } from '../errors.js';
2
+ import { RemoteError, RemoteNotConfiguredError, RemoteTimeoutError } from '../errors.js';
3
3
  import type {
4
4
  RemoteDocResponse,
5
5
  RemoteDocBulkResponse,
6
6
  RemoteCRResponse,
7
+ RemoteCRBulkResponse,
7
8
  RemoteBugResponse,
9
+ RemoteBugBulkResponse,
8
10
  } from './types.js';
9
11
 
12
+ /** Default timeout for remote operations (seconds) */
13
+ export const DEFAULT_REMOTE_TIMEOUT = 300;
14
+
15
+ /** Status codes that trigger a retry (server not ready / cold start) */
16
+ const RETRYABLE_STATUS_CODES = new Set([502, 503, 504]);
17
+
18
+ /** Initial backoff delay in ms */
19
+ const INITIAL_BACKOFF_MS = 3_000;
20
+
21
+ /** Maximum backoff delay in ms */
22
+ const MAX_BACKOFF_MS = 30_000;
23
+
10
24
  export interface ApiClientConfig {
11
25
  baseUrl: string;
12
26
  apiKey: string;
27
+ timeout: number;
13
28
  }
14
29
 
15
30
  /**
@@ -25,7 +40,12 @@ export function resolveApiKey(config: SDDConfig): string | null {
25
40
  * Build an ApiClientConfig from the SDD project config.
26
41
  * Throws RemoteNotConfiguredError if URL or API key is missing.
27
42
  */
28
- export function buildApiConfig(config: SDDConfig): ApiClientConfig {
43
+ /**
44
+ * Build an ApiClientConfig from the SDD project config.
45
+ * Throws RemoteNotConfiguredError if URL or API key is missing.
46
+ * An optional timeoutOverride (from --timeout flag) takes precedence over config.
47
+ */
48
+ export function buildApiConfig(config: SDDConfig, timeoutOverride?: number): ApiClientConfig {
29
49
  if (!config.remote?.url) {
30
50
  throw new RemoteNotConfiguredError();
31
51
  }
@@ -36,9 +56,24 @@ export function buildApiConfig(config: SDDConfig): ApiClientConfig {
36
56
  return {
37
57
  baseUrl: config.remote.url.replace(/\/+$/, ''),
38
58
  apiKey,
59
+ timeout: timeoutOverride ?? config.remote.timeout ?? DEFAULT_REMOTE_TIMEOUT,
39
60
  };
40
61
  }
41
62
 
63
+ function sleep(ms: number): Promise<void> {
64
+ return new Promise((resolve) => setTimeout(resolve, ms));
65
+ }
66
+
67
+ function isRetryable(error: unknown): boolean {
68
+ if (error instanceof RemoteError) {
69
+ return RETRYABLE_STATUS_CODES.has(error.statusCode);
70
+ }
71
+ // Network errors (ECONNREFUSED, ENOTFOUND, fetch abort due to connection failure)
72
+ if (error instanceof TypeError) return true;
73
+ const code = (error as NodeJS.ErrnoException).code;
74
+ return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ENOTFOUND' || code === 'UND_ERR_CONNECT_TIMEOUT';
75
+ }
76
+
42
77
  async function request<T>(
43
78
  config: ApiClientConfig,
44
79
  method: string,
@@ -51,24 +86,82 @@ async function request<T>(
51
86
  'Content-Type': 'application/json',
52
87
  };
53
88
 
54
- const res = await fetch(url, {
55
- method,
56
- headers,
57
- body: body != null ? JSON.stringify(body) : undefined,
58
- });
89
+ const deadline = Date.now() + config.timeout * 1_000;
90
+ let backoff = INITIAL_BACKOFF_MS;
91
+ let attempt = 0;
92
+
93
+ while (true) {
94
+ attempt++;
95
+ const remaining = deadline - Date.now();
96
+ if (remaining <= 0) {
97
+ throw new RemoteTimeoutError(config.timeout);
98
+ }
99
+
100
+ // Per-request timeout: min of remaining budget and 60s
101
+ const perRequestTimeout = Math.min(remaining, 60_000);
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), perRequestTimeout);
59
104
 
60
- if (!res.ok) {
61
- let message: string;
62
105
  try {
63
- const err = (await res.json()) as { detail?: string };
64
- message = err.detail ?? res.statusText;
65
- } catch {
66
- message = res.statusText;
106
+ const res = await fetch(url, {
107
+ method,
108
+ headers,
109
+ body: body != null ? JSON.stringify(body) : undefined,
110
+ signal: controller.signal,
111
+ });
112
+
113
+ clearTimeout(timer);
114
+
115
+ if (!res.ok) {
116
+ let message: string;
117
+ try {
118
+ const err = (await res.json()) as { detail?: string };
119
+ message = err.detail ?? res.statusText;
120
+ } catch {
121
+ message = res.statusText;
122
+ }
123
+ const remoteErr = new RemoteError(res.status, message);
124
+
125
+ if (RETRYABLE_STATUS_CODES.has(res.status) && Date.now() + backoff < deadline) {
126
+ process.stderr.write(` ⏳ Server not ready (${res.status}), retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
127
+ await sleep(backoff);
128
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
129
+ continue;
130
+ }
131
+
132
+ throw remoteErr;
133
+ }
134
+
135
+ return (await res.json()) as T;
136
+ } catch (error) {
137
+ clearTimeout(timer);
138
+
139
+ if (error instanceof RemoteError || error instanceof RemoteTimeoutError) {
140
+ throw error;
141
+ }
142
+
143
+ // Retryable network/connection error
144
+ if (isRetryable(error) && Date.now() + backoff < deadline) {
145
+ process.stderr.write(` ⏳ Cannot reach server, retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
146
+ await sleep(backoff);
147
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
148
+ continue;
149
+ }
150
+
151
+ // AbortError from our own timeout
152
+ if ((error as Error).name === 'AbortError') {
153
+ if (Date.now() + backoff < deadline) {
154
+ process.stderr.write(` ⏳ Request timed out, retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
155
+ await sleep(backoff);
156
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
157
+ continue;
158
+ }
159
+ throw new RemoteTimeoutError(config.timeout);
160
+ }
161
+
162
+ throw error;
67
163
  }
68
- throw new RemoteError(res.status, message);
69
164
  }
70
-
71
- return (await res.json()) as T;
72
165
  }
73
166
 
74
167
  /** GET /cli/pull-docs */
@@ -89,11 +182,27 @@ export async function fetchPendingCRs(config: ApiClientConfig): Promise<RemoteCR
89
182
  return request<RemoteCRResponse[]>(config, 'GET', '/cli/pending-crs');
90
183
  }
91
184
 
185
+ /** POST /cli/push-crs */
186
+ export async function pushCRs(
187
+ config: ApiClientConfig,
188
+ changeRequests: Array<{ path: string; title: string; body: string; id?: string }>,
189
+ ): Promise<RemoteCRBulkResponse> {
190
+ return request<RemoteCRBulkResponse>(config, 'POST', '/cli/push-crs', { change_requests: changeRequests });
191
+ }
192
+
92
193
  /** GET /cli/open-bugs */
93
194
  export async function fetchOpenBugs(config: ApiClientConfig): Promise<RemoteBugResponse[]> {
94
195
  return request<RemoteBugResponse[]>(config, 'GET', '/cli/open-bugs');
95
196
  }
96
197
 
198
+ /** POST /cli/push-bugs */
199
+ export async function pushBugs(
200
+ config: ApiClientConfig,
201
+ bugs: Array<{ path: string; title: string; body: string; severity?: string; id?: string }>,
202
+ ): Promise<RemoteBugBulkResponse> {
203
+ return request<RemoteBugBulkResponse>(config, 'POST', '/cli/push-bugs', { bugs });
204
+ }
205
+
97
206
  /** POST /cli/crs/:crId/applied */
98
207
  export async function markCRAppliedRemote(
99
208
  config: ApiClientConfig,
@@ -10,7 +10,7 @@ function stateFilePath(root: string): string {
10
10
  }
11
11
 
12
12
  function emptyState(): RemoteState {
13
- return { documents: {} };
13
+ return { documents: {}, changeRequests: {}, bugs: {} };
14
14
  }
15
15
 
16
16
  export async function readRemoteState(root: string): Promise<RemoteState> {
@@ -6,7 +6,9 @@ import matter from 'gray-matter';
6
6
 
7
7
  import { readConfig } from '../config/config-manager.js';
8
8
  import { parseAllStoryFiles } from '../parser/story-parser.js';
9
- import { buildApiConfig, pullDocs, pushDocs, fetchPendingCRs, fetchOpenBugs } from './api-client.js';
9
+ import { parseAllCRFiles } from '../parser/cr-parser.js';
10
+ import { parseAllBugFiles } from '../parser/bug-parser.js';
11
+ import { buildApiConfig, pullDocs, pushDocs, pushCRs, pushBugs, fetchPendingCRs, fetchOpenBugs } from './api-client.js';
10
12
  import { readRemoteState, writeRemoteState } from './state.js';
11
13
  import type {
12
14
  PushResult,
@@ -71,12 +73,21 @@ function buildBugMarkdown(title: string, body: string, createdAt: string, status
71
73
  export interface PushOptions {
72
74
  paths?: string[];
73
75
  all?: boolean;
76
+ timeout?: number;
74
77
  }
75
78
 
76
79
  export async function pushToRemote(root: string, options?: PushOptions): Promise<PushResult> {
77
80
  const config = await readConfig(root);
78
- const api = buildApiConfig(config);
81
+ const api = buildApiConfig(config, options?.timeout);
82
+ const state = await readRemoteState(root);
83
+ if (!state.changeRequests) state.changeRequests = {};
84
+ if (!state.bugs) state.bugs = {};
85
+
86
+ let totalCreated = 0;
87
+ let totalUpdated = 0;
88
+ const allPushed: string[] = [];
79
89
 
90
+ // ── Documents ──────────────────────────────────────────────────────────
80
91
  const files = await parseAllStoryFiles(root);
81
92
  const toPush = files.filter((f) => {
82
93
  if (options?.paths && options.paths.length > 0) return options.paths.includes(f.relativePath);
@@ -84,57 +95,141 @@ export async function pushToRemote(root: string, options?: PushOptions): Promise
84
95
  return f.frontmatter.status !== 'synced';
85
96
  });
86
97
 
87
- if (toPush.length === 0) {
88
- return { created: 0, updated: 0, pushed: [] };
98
+ if (toPush.length > 0) {
99
+ const documents = toPush.map((f) => ({
100
+ path: normalizePath(f.relativePath),
101
+ title: f.frontmatter.title,
102
+ content: f.body,
103
+ }));
104
+
105
+ const result = await pushDocs(api, documents);
106
+
107
+ for (const doc of result.documents) {
108
+ const localPath = doc.path;
109
+ const absPath = resolve(root, localPath);
110
+ const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
111
+ state.documents[localPath] = {
112
+ remoteId: doc.id,
113
+ remoteVersion: doc.version,
114
+ localHash: sha256(rawContent),
115
+ lastSynced: new Date().toISOString(),
116
+ };
117
+ }
118
+
119
+ // Mark local files as synced (drafts are excluded — they need AI enrichment first)
120
+ for (const f of toPush) {
121
+ if (f.frontmatter.status === 'draft') continue;
122
+ const absPath = resolve(root, f.relativePath);
123
+ const content = await readFile(absPath, 'utf-8');
124
+ const updated = content.replace(/^status:\s*(new|changed)/m, 'status: synced');
125
+ if (updated !== content) {
126
+ await writeFile(absPath, updated, 'utf-8');
127
+ }
128
+ }
129
+
130
+ totalCreated += result.created;
131
+ totalUpdated += result.updated;
132
+ allPushed.push(...toPush.map((f) => f.relativePath));
89
133
  }
90
134
 
91
- const documents = toPush.map((f) => ({
92
- path: normalizePath(f.relativePath),
93
- title: f.frontmatter.title,
94
- content: f.body,
95
- }));
135
+ // ── Change Requests ────────────────────────────────────────────────────
136
+ const crFiles = await parseAllCRFiles(root);
137
+ const crsToPush = crFiles.filter((cr) => {
138
+ if (options?.paths && options.paths.length > 0) return options.paths.includes(cr.relativePath);
139
+ if (options?.all) return true;
140
+ return cr.frontmatter.status !== 'applied';
141
+ });
96
142
 
97
- const result = await pushDocs(api, documents);
143
+ if (crsToPush.length > 0) {
144
+ const crPayload = crsToPush.map((cr) => {
145
+ const tracked = state.changeRequests![normalizePath(cr.relativePath)];
146
+ return {
147
+ path: normalizePath(cr.relativePath),
148
+ title: cr.frontmatter.title,
149
+ body: cr.body,
150
+ ...(tracked ? { id: tracked.remoteId } : {}),
151
+ };
152
+ });
153
+
154
+ const crResult = await pushCRs(api, crPayload);
155
+
156
+ for (const cr of crResult.change_requests) {
157
+ const localPath = crsToPush.find(
158
+ (f) => f.frontmatter.title === cr.title,
159
+ )?.relativePath;
160
+ if (localPath) {
161
+ const absPath = resolve(root, localPath);
162
+ const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
163
+ state.changeRequests![normalizePath(localPath)] = {
164
+ remoteId: cr.id,
165
+ localHash: sha256(rawContent),
166
+ lastSynced: new Date().toISOString(),
167
+ };
168
+ }
169
+ }
98
170
 
99
- // Update remote state
100
- const state = await readRemoteState(root);
101
- for (const doc of result.documents) {
102
- const localPath = doc.path;
103
- const absPath = resolve(root, localPath);
104
- const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
105
- state.documents[localPath] = {
106
- remoteId: doc.id,
107
- remoteVersion: doc.version,
108
- localHash: sha256(rawContent),
109
- lastSynced: new Date().toISOString(),
110
- };
171
+ totalCreated += crResult.created;
172
+ totalUpdated += crResult.updated;
173
+ allPushed.push(...crsToPush.map((cr) => cr.relativePath));
111
174
  }
112
- state.lastPush = new Date().toISOString();
113
- await writeRemoteState(root, state);
114
175
 
115
- // Mark local files as synced (drafts are excluded — they need AI enrichment first)
116
- for (const f of toPush) {
117
- if (f.frontmatter.status === 'draft') continue;
118
- const absPath = resolve(root, f.relativePath);
119
- const content = await readFile(absPath, 'utf-8');
120
- const updated = content.replace(/^status:\s*(new|changed)/m, 'status: synced');
121
- if (updated !== content) {
122
- await writeFile(absPath, updated, 'utf-8');
176
+ // ── Bugs ───────────────────────────────────────────────────────────────
177
+ const bugFiles = await parseAllBugFiles(root);
178
+ const bugsToPush = bugFiles.filter((bug) => {
179
+ if (options?.paths && options.paths.length > 0) return options.paths.includes(bug.relativePath);
180
+ if (options?.all) return true;
181
+ return bug.frontmatter.status !== 'resolved';
182
+ });
183
+
184
+ if (bugsToPush.length > 0) {
185
+ const bugPayload = bugsToPush.map((bug) => {
186
+ const tracked = state.bugs![normalizePath(bug.relativePath)];
187
+ return {
188
+ path: normalizePath(bug.relativePath),
189
+ title: bug.frontmatter.title,
190
+ body: bug.body,
191
+ ...(tracked ? { id: tracked.remoteId } : {}),
192
+ };
193
+ });
194
+
195
+ const bugResult = await pushBugs(api, bugPayload);
196
+
197
+ for (const bug of bugResult.bugs) {
198
+ const localPath = bugsToPush.find(
199
+ (b) => b.frontmatter.title === bug.title,
200
+ )?.relativePath;
201
+ if (localPath) {
202
+ const absPath = resolve(root, localPath);
203
+ const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
204
+ state.bugs![normalizePath(localPath)] = {
205
+ remoteId: bug.id,
206
+ localHash: sha256(rawContent),
207
+ lastSynced: new Date().toISOString(),
208
+ };
209
+ }
123
210
  }
211
+
212
+ totalCreated += bugResult.created;
213
+ totalUpdated += bugResult.updated;
214
+ allPushed.push(...bugsToPush.map((bug) => bug.relativePath));
124
215
  }
125
216
 
217
+ // ── Finalize ───────────────────────────────────────────────────────────
218
+ state.lastPush = new Date().toISOString();
219
+ await writeRemoteState(root, state);
220
+
126
221
  return {
127
- created: result.created,
128
- updated: result.updated,
129
- pushed: toPush.map((f) => f.relativePath),
222
+ created: totalCreated,
223
+ updated: totalUpdated,
224
+ pushed: allPushed,
130
225
  };
131
226
  }
132
227
 
133
228
  // ─── Pull Documents ──────────────────────────────────────────────────────
134
229
 
135
- export async function pullFromRemote(root: string): Promise<PullResult> {
230
+ export async function pullFromRemote(root: string, timeout?: number): Promise<PullResult> {
136
231
  const config = await readConfig(root);
137
- const api = buildApiConfig(config);
232
+ const api = buildApiConfig(config, timeout);
138
233
 
139
234
  const remoteDocs = await pullDocs(api);
140
235
  const state = await readRemoteState(root);
@@ -206,9 +301,9 @@ function updateDocState(
206
301
 
207
302
  // ─── Pull CRs ────────────────────────────────────────────────────────────
208
303
 
209
- export async function pullCRsFromRemote(root: string): Promise<PullEntitiesResult> {
304
+ export async function pullCRsFromRemote(root: string, timeout?: number): Promise<PullEntitiesResult> {
210
305
  const config = await readConfig(root);
211
- const api = buildApiConfig(config);
306
+ const api = buildApiConfig(config, timeout);
212
307
 
213
308
  const remoteCRs = await fetchPendingCRs(api);
214
309
  const crDir = resolve(root, 'change-requests');
@@ -238,9 +333,9 @@ export async function pullCRsFromRemote(root: string): Promise<PullEntitiesResul
238
333
 
239
334
  // ─── Pull Bugs ───────────────────────────────────────────────────────────
240
335
 
241
- export async function pullBugsFromRemote(root: string): Promise<PullEntitiesResult> {
336
+ export async function pullBugsFromRemote(root: string, timeout?: number): Promise<PullEntitiesResult> {
242
337
  const config = await readConfig(root);
243
- const api = buildApiConfig(config);
338
+ const api = buildApiConfig(config, timeout);
244
339
 
245
340
  const remoteBugs = await fetchOpenBugs(api);
246
341
  const bugsDir = resolve(root, 'bugs');
@@ -270,7 +365,7 @@ export async function pullBugsFromRemote(root: string): Promise<PullEntitiesResu
270
365
 
271
366
  // ─── Remote Status ───────────────────────────────────────────────────────
272
367
 
273
- export async function getRemoteStatus(root: string): Promise<RemoteStatusResult> {
368
+ export async function getRemoteStatus(root: string, timeout?: number): Promise<RemoteStatusResult> {
274
369
  const config = await readConfig(root);
275
370
 
276
371
  if (!config.remote?.url) {
@@ -281,7 +376,7 @@ export async function getRemoteStatus(root: string): Promise<RemoteStatusResult>
281
376
  const localPending = files.filter((f) => f.frontmatter.status !== 'synced').length;
282
377
 
283
378
  try {
284
- const api = buildApiConfig(config);
379
+ const api = buildApiConfig(config, timeout);
285
380
  const docs = await pullDocs(api);
286
381
  return {
287
382
  configured: true,
@@ -49,6 +49,20 @@ export interface RemoteBugResponse {
49
49
  updated_at: string;
50
50
  }
51
51
 
52
+ /** Bulk push response from POST /cli/push-crs */
53
+ export interface RemoteCRBulkResponse {
54
+ created: number;
55
+ updated: number;
56
+ change_requests: RemoteCRResponse[];
57
+ }
58
+
59
+ /** Bulk push response from POST /cli/push-bugs */
60
+ export interface RemoteBugBulkResponse {
61
+ created: number;
62
+ updated: number;
63
+ bugs: RemoteBugResponse[];
64
+ }
65
+
52
66
  /** Tracks per-document remote sync state */
53
67
  export interface RemoteDocState {
54
68
  remoteId: string;
@@ -57,11 +71,20 @@ export interface RemoteDocState {
57
71
  lastSynced: string;
58
72
  }
59
73
 
74
+ /** Tracks per-entity remote sync state (CRs, bugs) */
75
+ export interface RemoteEntityState {
76
+ remoteId: string;
77
+ localHash: string;
78
+ lastSynced: string;
79
+ }
80
+
60
81
  /** Persisted to .sdd/remote-state.json */
61
82
  export interface RemoteState {
62
83
  lastPull?: string;
63
84
  lastPush?: string;
64
85
  documents: Record<string, RemoteDocState>;
86
+ changeRequests?: Record<string, RemoteEntityState>;
87
+ bugs?: Record<string, RemoteEntityState>;
65
88
  }
66
89
 
67
90
  /** Result of a push operation */
package/src/sdd.ts CHANGED
@@ -254,29 +254,29 @@ export class SDD {
254
254
 
255
255
  // ── Remote sync ──────────────────────────────────────────────────────
256
256
 
257
- async remoteStatus(): Promise<RemoteStatusResult> {
257
+ async remoteStatus(timeout?: number): Promise<RemoteStatusResult> {
258
258
  this.ensureInitialized();
259
- return getRemoteStatus(this.root);
259
+ return getRemoteStatus(this.root, timeout);
260
260
  }
261
261
 
262
- async push(paths?: string[], options?: { all?: boolean }): Promise<PushResult> {
262
+ async push(paths?: string[], options?: { all?: boolean; timeout?: number }): Promise<PushResult> {
263
263
  this.ensureInitialized();
264
- return pushToRemote(this.root, { paths, all: options?.all });
264
+ return pushToRemote(this.root, { paths, all: options?.all, timeout: options?.timeout });
265
265
  }
266
266
 
267
- async pull(): Promise<PullResult> {
267
+ async pull(timeout?: number): Promise<PullResult> {
268
268
  this.ensureInitialized();
269
- return pullFromRemote(this.root);
269
+ return pullFromRemote(this.root, timeout);
270
270
  }
271
271
 
272
- async pullCRs(): Promise<PullEntitiesResult> {
272
+ async pullCRs(timeout?: number): Promise<PullEntitiesResult> {
273
273
  this.ensureInitialized();
274
- return pullCRsFromRemote(this.root);
274
+ return pullCRsFromRemote(this.root, timeout);
275
275
  }
276
276
 
277
- async pullBugs(): Promise<PullEntitiesResult> {
277
+ async pullBugs(timeout?: number): Promise<PullEntitiesResult> {
278
278
  this.ensureInitialized();
279
- return pullBugsFromRemote(this.root);
279
+ return pullBugsFromRemote(this.root, timeout);
280
280
  }
281
281
 
282
282
  private ensureInitialized(): void {
package/src/types.ts CHANGED
@@ -58,6 +58,7 @@ export interface StoryStatus {
58
58
  export interface RemoteConfig {
59
59
  url: string;
60
60
  'api-key'?: string;
61
+ timeout?: number;
61
62
  }
62
63
 
63
64
  export interface SDDConfig {