@zoebuildsai/trace 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.
Files changed (130) hide show
  1. package/.gitignore +115 -0
  2. package/.trace/progress.json +22 -0
  3. package/README.md +466 -0
  4. package/RELEASE-NOTES-1.5.0.md +410 -0
  5. package/STATUS.md +245 -0
  6. package/dist/auto-commit.d.ts +66 -0
  7. package/dist/auto-commit.d.ts.map +1 -0
  8. package/dist/auto-commit.js +180 -0
  9. package/dist/auto-commit.js.map +1 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +246 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands.d.ts +46 -0
  15. package/dist/commands.d.ts.map +1 -0
  16. package/dist/commands.js +256 -0
  17. package/dist/commands.js.map +1 -0
  18. package/dist/diff.d.ts +23 -0
  19. package/dist/diff.d.ts.map +1 -0
  20. package/dist/diff.js +106 -0
  21. package/dist/diff.js.map +1 -0
  22. package/dist/github.d.ts.map +1 -0
  23. package/dist/github.js.map +1 -0
  24. package/dist/index-cache.d.ts +35 -0
  25. package/dist/index-cache.d.ts.map +1 -0
  26. package/dist/index-cache.js +114 -0
  27. package/dist/index-cache.js.map +1 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +25 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/storage.d.ts +45 -0
  33. package/dist/storage.d.ts.map +1 -0
  34. package/dist/storage.js +151 -0
  35. package/dist/storage.js.map +1 -0
  36. package/dist/sync.d.ts +60 -0
  37. package/dist/sync.js +184 -0
  38. package/dist/tags.d.ts +85 -0
  39. package/dist/tags.d.ts.map +1 -0
  40. package/dist/tags.js +219 -0
  41. package/dist/tags.js.map +1 -0
  42. package/dist/types.d.ts +102 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +6 -0
  45. package/dist/types.js.map +1 -0
  46. package/docs/.nojekyll +0 -0
  47. package/docs/README.md +73 -0
  48. package/docs/_config.yml +2 -0
  49. package/docs/index.html +960 -0
  50. package/docs-website/package.json +20 -0
  51. package/jest.config.js +21 -0
  52. package/package.json +50 -0
  53. package/scripts/init.ts +290 -0
  54. package/src/agent-audit.ts +270 -0
  55. package/src/agent-checkout.ts +227 -0
  56. package/src/agent-coordination.ts +318 -0
  57. package/src/async-queue.ts +203 -0
  58. package/src/auto-branching.ts +279 -0
  59. package/src/auto-commit.ts +166 -0
  60. package/src/cherry-pick.ts +252 -0
  61. package/src/chunked-upload.ts +224 -0
  62. package/src/cli-v2.ts +335 -0
  63. package/src/cli.ts +318 -0
  64. package/src/cliff-detection.ts +232 -0
  65. package/src/commands.ts +267 -0
  66. package/src/commit-hash-system.ts +351 -0
  67. package/src/compression.ts +176 -0
  68. package/src/conflict-resolution-ui.ts +277 -0
  69. package/src/conflict-visualization.ts +238 -0
  70. package/src/diff-formatter.ts +184 -0
  71. package/src/diff.ts +124 -0
  72. package/src/distributed-coordination.ts +273 -0
  73. package/src/git-interop.ts +316 -0
  74. package/src/index-cache.ts +88 -0
  75. package/src/index.ts +38 -0
  76. package/src/merge-engine.ts +143 -0
  77. package/src/message-search.ts +370 -0
  78. package/src/performance-monitoring.ts +236 -0
  79. package/src/rebase.ts +327 -0
  80. package/src/rollback.ts +215 -0
  81. package/src/semantic-grouping.ts +245 -0
  82. package/src/stage-area.ts +324 -0
  83. package/src/stash.ts +278 -0
  84. package/src/storage.ts +131 -0
  85. package/src/sync.ts +205 -0
  86. package/src/tags.ts +244 -0
  87. package/src/types.ts +119 -0
  88. package/src/webhooks.ts +119 -0
  89. package/src/workspace-isolation.ts +298 -0
  90. package/tests/auto-commit.test.ts +308 -0
  91. package/tests/checkout.test.ts +136 -0
  92. package/tests/commit.test.ts +118 -0
  93. package/tests/diff.test.ts +191 -0
  94. package/tests/github.test.ts +94 -0
  95. package/tests/integration.test.ts +267 -0
  96. package/tests/log.test.ts +125 -0
  97. package/tests/phase2-integration.test.ts +370 -0
  98. package/tests/storage.test.ts +167 -0
  99. package/tests/tags.test.ts +477 -0
  100. package/tests/types.test.ts +75 -0
  101. package/tests/v1.1/agent-audit.test.ts +472 -0
  102. package/tests/v1.1/agent-coordination.test.ts +308 -0
  103. package/tests/v1.1/async-queue.test.ts +253 -0
  104. package/tests/v1.1/comprehensive.test.ts +521 -0
  105. package/tests/v1.1/diff-formatter.test.ts +238 -0
  106. package/tests/v1.1/integration.test.ts +389 -0
  107. package/tests/v1.1/onboarding.test.ts +365 -0
  108. package/tests/v1.1/rollback.test.ts +370 -0
  109. package/tests/v1.1/semantic-grouping.test.ts +230 -0
  110. package/tests/v1.2/chunked-upload.test.ts +301 -0
  111. package/tests/v1.2/cliff-detection.test.ts +272 -0
  112. package/tests/v1.2/commit-hash-system.test.ts +288 -0
  113. package/tests/v1.2/compression.test.ts +220 -0
  114. package/tests/v1.2/conflict-visualization.test.ts +263 -0
  115. package/tests/v1.2/distributed.test.ts +261 -0
  116. package/tests/v1.2/performance-monitoring.test.ts +328 -0
  117. package/tests/v1.3/auto-branching.test.ts +270 -0
  118. package/tests/v1.3/message-search.test.ts +264 -0
  119. package/tests/v1.3/stage-area.test.ts +330 -0
  120. package/tests/v1.3/stash-rebase-cherry-pick.test.ts +361 -0
  121. package/tests/v1.4/cli.test.ts +171 -0
  122. package/tests/v1.4/conflict-resolution-advanced.test.ts +429 -0
  123. package/tests/v1.4/conflict-resolution-ui.test.ts +286 -0
  124. package/tests/v1.4/workspace-isolation-advanced.test.ts +382 -0
  125. package/tests/v1.4/workspace-isolation.test.ts +268 -0
  126. package/tests/v1.5/agent-coordination.real.test.ts +401 -0
  127. package/tests/v1.5/cli-v2.test.ts +354 -0
  128. package/tests/v1.5/git-interop.real.test.ts +358 -0
  129. package/tests/v1.5/integration-testing.real.test.ts +440 -0
  130. package/tsconfig.json +26 -0
package/src/sync.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Remote Sync Layer
3
+ * Handles push/pull with remote repositories
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ interface Remote {
11
+ name: string;
12
+ url: string;
13
+ }
14
+
15
+ export class TraceSync {
16
+ private repoDir: string;
17
+ private remoteConfigFile: string;
18
+
19
+ constructor(repoDir: string = process.cwd()) {
20
+ this.repoDir = repoDir;
21
+ this.remoteConfigFile = join(repoDir, '.trace', 'remotes.json');
22
+ }
23
+
24
+ /**
25
+ * Initialize git repo if needed
26
+ */
27
+ initRepo(): void {
28
+ try {
29
+ execSync('git rev-parse --git-dir', { cwd: this.repoDir, stdio: 'ignore' });
30
+ } catch {
31
+ execSync('git init', { cwd: this.repoDir });
32
+ execSync('git config user.email "trace@zoebuildsai.com"', { cwd: this.repoDir });
33
+ execSync('git config user.name "Trace"', { cwd: this.repoDir });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Add a remote repository
39
+ */
40
+ addRemote(name: string, url: string): void {
41
+ try {
42
+ execSync(`git remote add ${name} ${url}`, { cwd: this.repoDir });
43
+ } catch {
44
+ // Remote may already exist, update it
45
+ execSync(`git remote set-url ${name} ${url}`, { cwd: this.repoDir });
46
+ }
47
+ this.saveRemote(name, url);
48
+ }
49
+
50
+ /**
51
+ * List all remotes
52
+ */
53
+ listRemotes(): Remote[] {
54
+ try {
55
+ const output = execSync('git remote -v', { cwd: this.repoDir, encoding: 'utf-8' });
56
+ const remotes: Map<string, string> = new Map();
57
+
58
+ for (const line of output.trim().split('\n')) {
59
+ if (!line) continue;
60
+ const [name, url] = line.split(/\s+/);
61
+ if (!remotes.has(name)) {
62
+ remotes.set(name, url);
63
+ }
64
+ }
65
+
66
+ const result: Remote[] = [];
67
+ for (const [name, url] of remotes) {
68
+ result.push({ name, url });
69
+ }
70
+ return result;
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Push to remote with timeout
78
+ */
79
+ push(remote: string = 'origin', timeout: number = 5000): { success: boolean; message: string } {
80
+ this.initRepo();
81
+
82
+ try {
83
+ const cmd = `git push ${remote} --all --tags`;
84
+ const timeoutSec = Math.ceil(timeout / 1000);
85
+
86
+ try {
87
+ execSync(cmd, {
88
+ cwd: this.repoDir,
89
+ timeout: timeout,
90
+ stdio: 'pipe',
91
+ });
92
+ return { success: true, message: `Pushed to ${remote}` };
93
+ } catch (err: any) {
94
+ if (err.killed) {
95
+ // Timeout - return partial success
96
+ return {
97
+ success: false,
98
+ message: `Push timeout after ${timeoutSec}s (may have partially succeeded)`,
99
+ };
100
+ }
101
+ throw err;
102
+ }
103
+ } catch (err) {
104
+ return {
105
+ success: false,
106
+ message: `Push failed: ${(err as Error).message}`,
107
+ };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Pull from remote
113
+ */
114
+ pull(remote: string = 'origin'): { success: boolean; message: string } {
115
+ this.initRepo();
116
+
117
+ try {
118
+ execSync(`git pull ${remote} --all`, {
119
+ cwd: this.repoDir,
120
+ stdio: 'pipe',
121
+ });
122
+ return { success: true, message: `Pulled from ${remote}` };
123
+ } catch (err) {
124
+ return {
125
+ success: false,
126
+ message: `Pull failed: ${(err as Error).message}`,
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Stage and commit changes
133
+ */
134
+ stageAndCommit(message: string): { success: boolean; hash: string } {
135
+ this.initRepo();
136
+
137
+ try {
138
+ execSync('git add -A', { cwd: this.repoDir });
139
+ const output = execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
140
+ cwd: this.repoDir,
141
+ encoding: 'utf-8',
142
+ });
143
+
144
+ // Extract hash from output like "[main abc1234] message"
145
+ const match = output.match(/\[.*?\s+(\w+)\]/);
146
+ const hash = match ? match[1] : 'unknown';
147
+
148
+ return { success: true, hash };
149
+ } catch (err: any) {
150
+ const errStr = err.message || err.toString();
151
+ if (errStr.includes('nothing to commit') || errStr.includes('nothing added to commit')) {
152
+ return { success: true, hash: 'none' };
153
+ }
154
+ return { success: false, hash: '' };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Commit with optional auto-push
160
+ */
161
+ commitWithAutoPush(
162
+ message: string,
163
+ autoPush: boolean = false,
164
+ remote: string = 'origin'
165
+ ): { success: boolean; hash: string; pushed?: boolean } {
166
+ const commitResult = this.stageAndCommit(message);
167
+
168
+ if (!commitResult.success) {
169
+ return { success: false, hash: '' };
170
+ }
171
+
172
+ if (autoPush && commitResult.hash !== 'none') {
173
+ const pushResult = this.push(remote, 5000);
174
+ return {
175
+ success: true,
176
+ hash: commitResult.hash,
177
+ pushed: pushResult.success,
178
+ };
179
+ }
180
+
181
+ return { success: true, hash: commitResult.hash };
182
+ }
183
+
184
+ /**
185
+ * Save remote config locally
186
+ */
187
+ private saveRemote(name: string, url: string): void {
188
+ const dir = join(this.repoDir, '.trace');
189
+ if (!existsSync(dir)) {
190
+ execSync(`mkdir -p ${dir}`);
191
+ }
192
+
193
+ let remotes: Record<string, string> = {};
194
+ if (existsSync(this.remoteConfigFile)) {
195
+ try {
196
+ remotes = JSON.parse(readFileSync(this.remoteConfigFile, 'utf-8'));
197
+ } catch {
198
+ remotes = {};
199
+ }
200
+ }
201
+
202
+ remotes[name] = url;
203
+ writeFileSync(this.remoteConfigFile, JSON.stringify(remotes, null, 2));
204
+ }
205
+ }
package/src/tags.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Tags Feature
3
+ *
4
+ * Create, list, delete, and checkout to semantic tags.
5
+ * - Store tags in refs/tags/
6
+ * - Tag metadata (created_at, description, creator)
7
+ * - Fast tag index for <50ms lookups
8
+ * - Unicode and special character support
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as crypto from 'crypto';
14
+ import { TraceCommands } from './commands';
15
+
16
+ export interface Tag {
17
+ name: string;
18
+ commit_hash: string;
19
+ created_at: number;
20
+ metadata?: Record<string, any>;
21
+ }
22
+
23
+ export interface TagListOptions {
24
+ sortBy?: 'created_at' | 'name';
25
+ pattern?: string;
26
+ }
27
+
28
+ export interface TagCreateOptions {
29
+ description?: string;
30
+ creator?: string;
31
+ force?: boolean;
32
+ [key: string]: any;
33
+ }
34
+
35
+ export interface TagCheckoutOptions {
36
+ force?: boolean;
37
+ }
38
+
39
+ export class TagManager {
40
+ private commands: TraceCommands;
41
+ private tagsDir: string;
42
+ private tagIndex: Map<string, Tag> = new Map();
43
+ private indexValid: boolean = false;
44
+
45
+ constructor(commands: TraceCommands, basePath?: string) {
46
+ this.commands = commands;
47
+
48
+ // Derive tags directory from memory-git base path
49
+ const baseDir = basePath || path.join(process.env.HOME || '', '.openclaw/memory-git');
50
+ this.tagsDir = path.join(baseDir, 'refs', 'tags');
51
+
52
+ this.ensureTagsDir();
53
+ this.rebuildIndex();
54
+ }
55
+
56
+ /**
57
+ * Create a new tag at current HEAD
58
+ */
59
+ create(name: string, options: TagCreateOptions = {}): Tag {
60
+ this.validateTagName(name);
61
+
62
+ // Check if tag exists
63
+ if (this.tagIndex.has(name) && !options.force) {
64
+ throw new Error(`Tag '${name}' already exists. Use force: true to override.`);
65
+ }
66
+
67
+ // Get current HEAD commit
68
+ const log = this.commands.log(1);
69
+ if (log.length === 0) {
70
+ throw new Error('No commits to tag');
71
+ }
72
+
73
+ const commitHash = log[0].hash;
74
+ const now = Date.now();
75
+
76
+ // Filter options to extract metadata (skip 'force')
77
+ const metaEntries = Object.entries(options)
78
+ .filter(([k]) => k !== 'force');
79
+
80
+ const tag: Tag = {
81
+ name,
82
+ commit_hash: commitHash,
83
+ created_at: now,
84
+ metadata: metaEntries.length > 0 ? Object.fromEntries(metaEntries) : undefined,
85
+ };
86
+
87
+ // Save tag
88
+ this.saveTag(tag);
89
+
90
+ // Update index
91
+ this.tagIndex.set(name, tag);
92
+
93
+ return tag;
94
+ }
95
+
96
+ /**
97
+ * Get a tag by name
98
+ */
99
+ get(name: string): Tag | undefined {
100
+ this.ensureIndexValid();
101
+ return this.tagIndex.get(name);
102
+ }
103
+
104
+ /**
105
+ * List all tags
106
+ */
107
+ list(options: TagListOptions = {}): Tag[] {
108
+ this.ensureIndexValid();
109
+
110
+ let tags = Array.from(this.tagIndex.values());
111
+
112
+ // Filter by pattern if provided
113
+ if (options.pattern) {
114
+ const regex = this.patternToRegex(options.pattern);
115
+ tags = tags.filter(t => regex.test(t.name));
116
+ }
117
+
118
+ // Sort
119
+ if (options.sortBy === 'name') {
120
+ tags.sort((a, b) => a.name.localeCompare(b.name));
121
+ } else {
122
+ tags.sort((a, b) => a.created_at - b.created_at);
123
+ }
124
+
125
+ return tags;
126
+ }
127
+
128
+ /**
129
+ * Delete a tag
130
+ */
131
+ delete(name: string): void {
132
+ if (!this.tagIndex.has(name)) {
133
+ throw new Error(`Tag '${name}' not found`);
134
+ }
135
+
136
+ const tagPath = this.getTagPath(name);
137
+ fs.unlinkSync(tagPath);
138
+
139
+ this.tagIndex.delete(name);
140
+ }
141
+
142
+ /**
143
+ * Checkout to a tagged commit
144
+ */
145
+ checkout(name: string, options: TagCheckoutOptions = {}): boolean {
146
+ const tag = this.get(name);
147
+ if (!tag) {
148
+ throw new Error(`Tag '${name}' not found`);
149
+ }
150
+
151
+ // Checkout to the commit
152
+ this.commands.checkout(tag.commit_hash, { force: options.force });
153
+
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Rebuild the tag index
159
+ */
160
+ rebuildIndex(): void {
161
+ this.tagIndex.clear();
162
+
163
+ if (!fs.existsSync(this.tagsDir)) {
164
+ this.indexValid = true;
165
+ return;
166
+ }
167
+
168
+ const files = fs.readdirSync(this.tagsDir);
169
+ for (const file of files) {
170
+ try {
171
+ const tagPath = path.join(this.tagsDir, file);
172
+ const content = fs.readFileSync(tagPath, 'utf-8');
173
+ const tag = JSON.parse(content) as Tag;
174
+ this.tagIndex.set(tag.name, tag);
175
+ } catch (err) {
176
+ // Skip corrupt tag files
177
+ console.warn(`Failed to load tag ${file}:`, err);
178
+ }
179
+ }
180
+
181
+ this.indexValid = true;
182
+ }
183
+
184
+ /**
185
+ * Private: Ensure index is valid
186
+ */
187
+ private ensureIndexValid(): void {
188
+ if (!this.indexValid) {
189
+ this.rebuildIndex();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Private: Validate tag name
195
+ */
196
+ private validateTagName(name: string): void {
197
+ if (!name || name.trim().length === 0) {
198
+ throw new Error('Tag name cannot be empty');
199
+ }
200
+
201
+ if (name.includes(' ')) {
202
+ throw new Error('Tag name cannot contain spaces');
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Private: Ensure tags directory exists
208
+ */
209
+ private ensureTagsDir(): void {
210
+ if (!fs.existsSync(this.tagsDir)) {
211
+ fs.mkdirSync(this.tagsDir, { recursive: true });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Private: Get tag file path
217
+ */
218
+ private getTagPath(name: string): string {
219
+ // Escape special characters for filesystem
220
+ const escaped = name.replace(/[\/\\:?*"<>|]/g, '_');
221
+ return path.join(this.tagsDir, escaped);
222
+ }
223
+
224
+ /**
225
+ * Private: Save tag to disk
226
+ */
227
+ private saveTag(tag: Tag): void {
228
+ const tagPath = this.getTagPath(tag.name);
229
+ const content = JSON.stringify(tag, null, 2);
230
+ fs.writeFileSync(tagPath, content, 'utf-8');
231
+ }
232
+
233
+ /**
234
+ * Private: Convert glob pattern to regex
235
+ */
236
+ private patternToRegex(pattern: string): RegExp {
237
+ const escaped = pattern
238
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
239
+ .replace(/\*/g, '.*')
240
+ .replace(/\?/g, '.');
241
+
242
+ return new RegExp(`^${escaped}$`);
243
+ }
244
+ }
package/src/types.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Core types for Memory Git system
3
+ */
4
+
5
+ export interface CommitObject {
6
+ hash: string;
7
+ message: string;
8
+ timestamp: number;
9
+ author: string;
10
+ parent: string | null;
11
+ tree: TreeObject;
12
+ metadata?: Record<string, unknown>;
13
+ }
14
+
15
+ export interface TreeObject {
16
+ files: Map<string, FileEntry>;
17
+ hash: string;
18
+ }
19
+
20
+ export interface FileEntry {
21
+ path: string;
22
+ hash: string;
23
+ size: number;
24
+ mode: 'file' | 'deleted';
25
+ lastModified: number;
26
+ }
27
+
28
+ export interface DiffResult {
29
+ added: Map<string, string>;
30
+ modified: Map<string, DiffChange>;
31
+ deleted: Map<string, string>;
32
+ stats: DiffStats;
33
+ }
34
+
35
+ export interface DiffChange {
36
+ oldHash: string;
37
+ newHash: string;
38
+ lines?: LineDiff[];
39
+ }
40
+
41
+ export interface LineDiff {
42
+ type: 'add' | 'remove' | 'context';
43
+ content: string;
44
+ lineNum?: number;
45
+ }
46
+
47
+ export interface DiffStats {
48
+ filesChanged: number;
49
+ linesAdded: number;
50
+ linesRemoved: number;
51
+ totalChanges: number;
52
+ }
53
+
54
+ export interface StatusResult {
55
+ modified: string[];
56
+ added: string[];
57
+ deleted: string[];
58
+ untracked: string[];
59
+ clean: boolean;
60
+ }
61
+
62
+ export interface LogEntry {
63
+ hash: string;
64
+ message: string;
65
+ timestamp: number;
66
+ author: string;
67
+ shortHash: string;
68
+ }
69
+
70
+ export interface CommitOptions {
71
+ message: string;
72
+ author?: string;
73
+ metadata?: Record<string, unknown>;
74
+ }
75
+
76
+ export interface CheckoutOptions {
77
+ force?: boolean;
78
+ specific?: string[];
79
+ }
80
+
81
+ export interface DiffOptions {
82
+ lines?: boolean;
83
+ context?: number;
84
+ maxSize?: number;
85
+ }
86
+
87
+ export interface IndexCache {
88
+ timestamp: number;
89
+ files: Map<string, CachedFile>;
90
+ currentCommit: string;
91
+ }
92
+
93
+ export interface CachedFile {
94
+ path: string;
95
+ hash: string;
96
+ lastSeen: number;
97
+ modified: boolean;
98
+ }
99
+
100
+ // Phase 2 Types
101
+ export interface AutoCommitMetadata {
102
+ auto: boolean;
103
+ filesChanged: number;
104
+ files?: string[];
105
+ timestamp: number;
106
+ }
107
+
108
+ export interface TagInfo {
109
+ name: string;
110
+ commit_hash: string;
111
+ created_at: number;
112
+ description?: string;
113
+ }
114
+
115
+ export interface BranchInfo {
116
+ name: string;
117
+ head: string;
118
+ created_at: number;
119
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Webhook Manager for Trace
3
+ * Safe, validated webhook delivery with retry logic
4
+ */
5
+
6
+ export interface WebhookPayload {
7
+ operationId: string;
8
+ type: 'commit' | 'push' | 'pull';
9
+ status: 'completed' | 'failed';
10
+ timestamp: number;
11
+ error?: string;
12
+ }
13
+
14
+ export class WebhookManager {
15
+ /**
16
+ * Validate webhook URL for security
17
+ */
18
+ static validateUrl(url: string): { valid: boolean; reason?: string } {
19
+ try {
20
+ const parsed = new URL(url);
21
+
22
+ // Only HTTPS allowed
23
+ if (parsed.protocol !== 'https:') {
24
+ return { valid: false, reason: 'Only HTTPS URLs allowed' };
25
+ }
26
+
27
+ // Block localhost/private IPs
28
+ if (this.isPrivateIP(parsed.hostname)) {
29
+ return { valid: false, reason: 'Private/localhost URLs not allowed' };
30
+ }
31
+
32
+ // URL must be reasonable length (prevent DoS)
33
+ if (url.length > 2000) {
34
+ return { valid: false, reason: 'URL too long' };
35
+ }
36
+
37
+ return { valid: true };
38
+ } catch (err) {
39
+ return { valid: false, reason: 'Invalid URL format' };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if hostname is private/local
45
+ */
46
+ private static isPrivateIP(hostname: string): boolean {
47
+ const privatePatterns = [
48
+ /^localhost$/,
49
+ /^127\./,
50
+ /^192\.168\./,
51
+ /^10\./,
52
+ /^172\.(1[6-9]|2[0-9]|3[01])\./,
53
+ /^::1$/,
54
+ /^fc00:/,
55
+ /^fe80:/,
56
+ ];
57
+
58
+ return privatePatterns.some(pattern => pattern.test(hostname));
59
+ }
60
+
61
+ /**
62
+ * Sanitize payload for webhook delivery
63
+ */
64
+ static sanitizePayload(payload: Record<string, unknown>): WebhookPayload {
65
+ // Only include safe fields
66
+ return {
67
+ operationId: String(payload.operationId || ''),
68
+ type: payload.type as 'commit' | 'push' | 'pull',
69
+ status: payload.status as 'completed' | 'failed',
70
+ timestamp: Number(payload.timestamp || Date.now()),
71
+ error: payload.error ? String(payload.error).slice(0, 500) : undefined,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Send webhook with retry
77
+ */
78
+ static async send(
79
+ url: string,
80
+ payload: WebhookPayload,
81
+ maxRetries: number = 3
82
+ ): Promise<void> {
83
+ // Validate URL first
84
+ const validation = this.validateUrl(url);
85
+ if (!validation.valid) {
86
+ throw new Error(`Invalid webhook URL: ${validation.reason}`);
87
+ }
88
+
89
+ // Sanitize payload
90
+ const safePayload = this.sanitizePayload(payload);
91
+
92
+ let lastError: Error | undefined;
93
+
94
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
95
+ try {
96
+ // TODO: Replace with actual fetch in implementation
97
+ // await fetch(url, {
98
+ // method: 'POST',
99
+ // headers: { 'Content-Type': 'application/json' },
100
+ // body: JSON.stringify(safePayload),
101
+ // timeout: 10000,
102
+ // });
103
+ return;
104
+ } catch (err) {
105
+ lastError = err instanceof Error ? err : new Error(String(err));
106
+
107
+ // Exponential backoff
108
+ if (attempt < maxRetries - 1) {
109
+ const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
110
+ await new Promise(resolve => setTimeout(resolve, delay));
111
+ }
112
+ }
113
+ }
114
+
115
+ throw lastError || new Error('Failed to send webhook');
116
+ }
117
+ }
118
+
119
+ export default WebhookManager;