taskode 0.4.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.
@@ -0,0 +1,339 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import yaml from 'js-yaml';
5
+
6
+ const DEFAULT_WORKSPACE_IGNORES = ['.git', '.taskode', 'node_modules'];
7
+ const VALID_TRACKERS = new Set(['local', 'linear', 'github']);
8
+ const VALID_CODEX_MODES = new Set(['shell', 'app-server']);
9
+
10
+ export function loadWorkflow(workflowPath) {
11
+ if (!fs.existsSync(workflowPath)) {
12
+ throw new Error(`WORKFLOW file not found: ${workflowPath}`);
13
+ }
14
+
15
+ const raw = fs.readFileSync(workflowPath, 'utf8');
16
+ const { frontMatter, body } = parseFrontMatter(raw);
17
+ const config = frontMatter ? yaml.load(frontMatter) || {} : {};
18
+
19
+ if (!isPlainObject(config)) {
20
+ throw new Error('WORKFLOW front matter must be a YAML object');
21
+ }
22
+
23
+ return {
24
+ workflowPath,
25
+ config,
26
+ promptTemplate: (body || '').trim() || defaultPromptTemplate()
27
+ };
28
+ }
29
+
30
+ export function resolveRuntimeConfig(workflow, cliOptions = {}) {
31
+ const config = workflow.config || {};
32
+ const projectRoot = process.cwd();
33
+ const trackerKind = normalizeTrackerKind(config.tracker?.kind);
34
+
35
+ const workspaceRoot = normalizePath(
36
+ envOrValue(config.workspace?.root) || path.join(projectRoot, '.taskode', 'workspaces')
37
+ );
38
+
39
+ const logsRoot = normalizePath(
40
+ cliOptions.logsRoot || config.logs?.root || path.join(projectRoot, '.taskode', 'logs')
41
+ );
42
+
43
+ const runtime = {
44
+ projectRoot,
45
+ pollIntervalMs: requiredPositiveInteger(config.agent?.poll_interval_ms ?? 4000, 'agent.poll_interval_ms'),
46
+ maxConcurrentAgents: requiredPositiveInteger(
47
+ config.agent?.max_concurrent_agents ?? 2,
48
+ 'agent.max_concurrent_agents'
49
+ ),
50
+ maxRetryAttempts: requiredPositiveInteger(config.agent?.max_retry_attempts ?? 5, 'agent.max_retry_attempts'),
51
+ maxRetryBackoffMs: requiredPositiveInteger(
52
+ config.agent?.max_retry_backoff_ms ?? 60_000,
53
+ 'agent.max_retry_backoff_ms'
54
+ ),
55
+ maxConcurrentAgentsByState: normalizeStateLimits(config.agent?.max_concurrent_agents_by_state || {}),
56
+ workspaceRoot,
57
+ workspaceIgnoreNames: normalizeStringArray(config.workspace?.copy_ignore || DEFAULT_WORKSPACE_IGNORES),
58
+ seedWorkspaceFromProject: booleanOrDefault(config.workspace?.seed_from_project, true),
59
+ logsRoot,
60
+ tracker: {
61
+ kind: trackerKind,
62
+ apiKey: envOrValue(config.tracker?.api_key) || process.env.LINEAR_API_KEY || null,
63
+ projectSlug: config.tracker?.project_slug || null,
64
+ activeStates: normalizeStringArray(
65
+ config.tracker?.active_states || ['Todo', 'In Progress', 'Rework', 'Backlog', 'todo', 'in_progress']
66
+ ),
67
+ terminalStates: normalizeStringArray(
68
+ config.tracker?.terminal_states || ['Done', 'Closed', 'Cancelled', 'Duplicate', 'done', 'closed', 'cancelled', 'duplicate']
69
+ ),
70
+ githubToken: envOrValue(config.tracker?.github_token) || process.env.GITHUB_TOKEN || null,
71
+ githubOwner: normalizeOptionalString(config.tracker?.github_owner),
72
+ githubRepo: normalizeOptionalString(config.tracker?.github_repo),
73
+ githubLabels: normalizeStringArray(config.tracker?.github_labels || []),
74
+ githubBaseUrl: normalizeOptionalString(config.tracker?.github_base_url) || 'https://api.github.com'
75
+ },
76
+ worker: {
77
+ sshHosts: normalizeStringArray(config.worker?.ssh_hosts || []),
78
+ maxConcurrentAgentsPerHost: optionalPositiveInteger(
79
+ config.worker?.max_concurrent_agents_per_host,
80
+ 'worker.max_concurrent_agents_per_host'
81
+ )
82
+ },
83
+ codex: {
84
+ command: requiredNonEmptyString(envOrValue(config.codex?.command) || 'echo "simulated codex run"', 'codex.command'),
85
+ timeoutMs: requiredPositiveInteger(config.codex?.timeout_ms ?? 120_000, 'codex.timeout_ms'),
86
+ mode: normalizeCodexMode(config.codex?.mode || 'shell'),
87
+ maxTurns: requiredPositiveInteger(config.agent?.max_turns ?? 20, 'agent.max_turns'),
88
+ approvalPolicy: normalizeApprovalPolicy(config.codex?.approval_policy),
89
+ threadSandbox: normalizeOptionalString(config.codex?.thread_sandbox) || 'workspace-write',
90
+ turnSandboxPolicy: normalizeOptionalObject(config.codex?.turn_sandbox_policy, 'codex.turn_sandbox_policy'),
91
+ turnTimeoutMs: requiredPositiveInteger(config.codex?.turn_timeout_ms ?? 3_600_000, 'codex.turn_timeout_ms'),
92
+ readTimeoutMs: requiredPositiveInteger(config.codex?.read_timeout_ms ?? 5_000, 'codex.read_timeout_ms'),
93
+ stallTimeoutMs: requiredNonNegativeInteger(config.codex?.stall_timeout_ms ?? 300_000, 'codex.stall_timeout_ms')
94
+ },
95
+ hooks: {
96
+ afterCreate: normalizeOptionalString(config.hooks?.after_create),
97
+ beforeRun: normalizeOptionalString(config.hooks?.before_run),
98
+ afterRun: normalizeOptionalString(config.hooks?.after_run),
99
+ beforeRemove: normalizeOptionalString(config.hooks?.before_remove),
100
+ afterRemove: normalizeOptionalString(config.hooks?.after_remove),
101
+ timeoutMs: requiredPositiveInteger(config.hooks?.timeout_ms ?? 60_000, 'hooks.timeout_ms')
102
+ },
103
+ review: {
104
+ autoCleanupApproved: booleanOrDefault(config.review?.auto_cleanup_approved, true)
105
+ },
106
+ server: {
107
+ host: normalizeOptionalString(config.server?.host) || '127.0.0.1',
108
+ port: requiredNonNegativeInteger(cliOptions.port ?? config.server?.port ?? 4317, 'server.port')
109
+ },
110
+ auth: {
111
+ token: normalizeOptionalString(envOrValue(config.auth?.token) || process.env.TASKODE_API_TOKEN || null)
112
+ },
113
+ observability: {
114
+ dashboardEnabled: booleanOrDefault(config.observability?.dashboard_enabled, true),
115
+ refreshMs: requiredPositiveInteger(config.observability?.refresh_ms ?? 1000, 'observability.refresh_ms'),
116
+ renderIntervalMs: requiredPositiveInteger(
117
+ config.observability?.render_interval_ms ?? 16,
118
+ 'observability.render_interval_ms'
119
+ )
120
+ },
121
+ policy: {
122
+ allowCommands: normalizeStringArray(config.policy?.allow_commands || ['*']),
123
+ denyCommands: normalizeStringArray(config.policy?.deny_commands || [])
124
+ },
125
+ promptTemplate: workflow.promptTemplate,
126
+ workflowPath: workflow.workflowPath
127
+ };
128
+
129
+ validateTrackerConfig(runtime.tracker);
130
+ validateTurnSandboxPolicy(runtime.codex.turnSandboxPolicy, runtime.workspaceRoot);
131
+
132
+ return runtime;
133
+ }
134
+
135
+ export function resolveTurnSandboxPolicy(config, workspacePath, options = {}) {
136
+ if (config.codex.turnSandboxPolicy) {
137
+ return config.codex.turnSandboxPolicy;
138
+ }
139
+
140
+ const writableRoot = options.remote ? String(workspacePath) : normalizePath(workspacePath || config.workspaceRoot);
141
+ return {
142
+ type: 'workspaceWrite',
143
+ writableRoots: [writableRoot],
144
+ networkAccess: false
145
+ };
146
+ }
147
+
148
+ export function replaceConfigContents(target, source) {
149
+ for (const key of Object.keys(target)) {
150
+ delete target[key];
151
+ }
152
+ Object.assign(target, source);
153
+ return target;
154
+ }
155
+
156
+ function parseFrontMatter(text) {
157
+ if (!text.startsWith('---\n')) {
158
+ return { frontMatter: '', body: text };
159
+ }
160
+
161
+ const closing = text.indexOf('\n---\n', 4);
162
+ if (closing === -1) {
163
+ throw new Error('Invalid WORKFLOW front matter: missing closing ---');
164
+ }
165
+
166
+ return {
167
+ frontMatter: text.slice(4, closing),
168
+ body: text.slice(closing + 5)
169
+ };
170
+ }
171
+
172
+ function defaultPromptTemplate() {
173
+ return 'You are working on issue {{ issue.identifier }} - {{ issue.title }}\n\n{{ issue.description }}';
174
+ }
175
+
176
+ function normalizeTrackerKind(value) {
177
+ const trackerKind = String(value || 'local').trim().toLowerCase();
178
+ if (!VALID_TRACKERS.has(trackerKind)) {
179
+ throw new Error(`Unsupported tracker kind: ${trackerKind}`);
180
+ }
181
+ return trackerKind;
182
+ }
183
+
184
+ function normalizeCodexMode(value) {
185
+ const mode = String(value || 'shell').trim().toLowerCase();
186
+ if (!VALID_CODEX_MODES.has(mode)) {
187
+ throw new Error(`Unsupported codex mode: ${mode}`);
188
+ }
189
+ return mode;
190
+ }
191
+
192
+ function envOrValue(value) {
193
+ if (typeof value !== 'string') return value;
194
+ if (!value.startsWith('$')) return value;
195
+ return process.env[value.slice(1)] || null;
196
+ }
197
+
198
+ function normalizePath(value) {
199
+ if (!value) return value;
200
+ if (typeof value !== 'string') {
201
+ throw new Error(`Expected path string, received ${typeof value}`);
202
+ }
203
+ if (value.startsWith('~/')) {
204
+ return path.join(os.homedir(), value.slice(2));
205
+ }
206
+ return path.resolve(value);
207
+ }
208
+
209
+ function validateTrackerConfig(tracker) {
210
+ if (tracker.kind === 'linear' && !tracker.projectSlug) {
211
+ throw new Error('tracker.project_slug is required for linear tracker');
212
+ }
213
+
214
+ if (tracker.kind === 'github' && (!tracker.githubOwner || !tracker.githubRepo)) {
215
+ throw new Error('tracker.github_owner and tracker.github_repo are required for github tracker');
216
+ }
217
+ }
218
+
219
+ function validateTurnSandboxPolicy(policy, workspaceRoot) {
220
+ if (!policy) return;
221
+ if (policy.type === 'workspaceWrite') {
222
+ const writableRoots = Array.isArray(policy.writableRoots) ? policy.writableRoots : [];
223
+ for (const root of writableRoots) {
224
+ if (typeof root !== 'string' || root.trim() === '') {
225
+ throw new Error('codex.turn_sandbox_policy.writableRoots entries must be non-empty strings');
226
+ }
227
+ if (!path.isAbsolute(root)) {
228
+ throw new Error(`codex.turn_sandbox_policy contains non-absolute writable root: ${root}`);
229
+ }
230
+ if (!normalizePath(root).startsWith(normalizePath(workspaceRoot))) {
231
+ throw new Error(`codex.turn_sandbox_policy root is outside workspace root: ${root}`);
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ function normalizeApprovalPolicy(value) {
238
+ if (value === undefined || value === null) {
239
+ return {
240
+ reject: {
241
+ sandbox_approval: true,
242
+ rules: true,
243
+ mcp_elicitations: true
244
+ }
245
+ };
246
+ }
247
+
248
+ if (typeof value === 'string') {
249
+ return value;
250
+ }
251
+
252
+ if (isPlainObject(value)) {
253
+ return value;
254
+ }
255
+
256
+ throw new Error('codex.approval_policy must be a string or object');
257
+ }
258
+
259
+ function normalizeStateLimits(value) {
260
+ if (!isPlainObject(value)) {
261
+ throw new Error('agent.max_concurrent_agents_by_state must be an object');
262
+ }
263
+
264
+ const normalized = {};
265
+ for (const [stateName, limit] of Object.entries(value)) {
266
+ normalized[String(stateName).trim().toLowerCase()] = requiredPositiveInteger(
267
+ limit,
268
+ `agent.max_concurrent_agents_by_state.${stateName}`
269
+ );
270
+ }
271
+ return normalized;
272
+ }
273
+
274
+ function normalizeOptionalObject(value, field) {
275
+ if (value === undefined || value === null) return null;
276
+ if (!isPlainObject(value)) {
277
+ throw new Error(`${field} must be an object`);
278
+ }
279
+ return value;
280
+ }
281
+
282
+ function normalizeOptionalString(value) {
283
+ if (value === undefined || value === null) return null;
284
+ if (typeof value !== 'string') {
285
+ throw new Error(`Expected string value, received ${typeof value}`);
286
+ }
287
+ const trimmed = value.trim();
288
+ return trimmed === '' ? null : trimmed;
289
+ }
290
+
291
+ function normalizeStringArray(value) {
292
+ if (!Array.isArray(value)) {
293
+ throw new Error('Expected an array of strings');
294
+ }
295
+
296
+ return [...new Set(value.map((entry) => String(entry || '').trim()).filter(Boolean))];
297
+ }
298
+
299
+ function requiredNonEmptyString(value, field) {
300
+ if (typeof value !== 'string' || value.trim() === '') {
301
+ throw new Error(`${field} must be a non-empty string`);
302
+ }
303
+ return value;
304
+ }
305
+
306
+ function requiredPositiveInteger(value, field) {
307
+ const number = Number(value);
308
+ if (!Number.isInteger(number) || number <= 0) {
309
+ throw new Error(`${field} must be a positive integer`);
310
+ }
311
+ return number;
312
+ }
313
+
314
+ function requiredNonNegativeInteger(value, field) {
315
+ const number = Number(value);
316
+ if (!Number.isInteger(number) || number < 0) {
317
+ throw new Error(`${field} must be a non-negative integer`);
318
+ }
319
+ return number;
320
+ }
321
+
322
+ function optionalPositiveInteger(value, field) {
323
+ if (value === undefined || value === null || value === '') {
324
+ return null;
325
+ }
326
+ return requiredPositiveInteger(value, field);
327
+ }
328
+
329
+ function booleanOrDefault(value, fallback) {
330
+ if (value === undefined || value === null) return fallback;
331
+ if (typeof value !== 'boolean') {
332
+ throw new Error(`Expected boolean value, received ${typeof value}`);
333
+ }
334
+ return value;
335
+ }
336
+
337
+ function isPlainObject(value) {
338
+ return !!value && typeof value === 'object' && !Array.isArray(value);
339
+ }
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { exec, spawn } from 'node:child_process';
4
+ import { assertPathInsideRoot, assertRemotePathSafe, canonicalizePath } from './path-safety.js';
5
+ import { runSSHCommand, shellEscape, startSSHProcess } from './ssh.js';
6
+
7
+ const REMOTE_WORKSPACE_MARKER = '__TASKODE_WORKSPACE__';
8
+
9
+ export class WorkspaceManager {
10
+ constructor({ config, store }) {
11
+ this.config = config;
12
+ this.store = store;
13
+ fs.mkdirSync(canonicalizePath(config.workspaceRoot), { recursive: true });
14
+ }
15
+
16
+ async ensure(issue, workerHost = null) {
17
+ const workspaceKey = sanitize(issue.identifier || issue.id);
18
+ const workspacePath = buildWorkspacePath(this.config.workspaceRoot, workspaceKey, workerHost);
19
+ const createdNow = workerHost
20
+ ? await ensureRemoteWorkspace(workerHost, workspacePath, this.config.hooks.timeoutMs)
21
+ : ensureLocalWorkspace(this.config.workspaceRoot, workspacePath);
22
+
23
+ if (createdNow && this.config.seedWorkspaceFromProject) {
24
+ if (workerHost) {
25
+ await syncProjectSnapshotToRemote({
26
+ projectRoot: this.config.projectRoot,
27
+ workspacePath,
28
+ workerHost,
29
+ ignoreNames: new Set(this.config.workspaceIgnoreNames || [])
30
+ });
31
+ } else {
32
+ copyProjectSnapshot(this.config.projectRoot, workspacePath, new Set(this.config.workspaceIgnoreNames || []));
33
+ }
34
+ }
35
+
36
+ if (createdNow && this.config.hooks.afterCreate) {
37
+ await runHook({
38
+ command: this.config.hooks.afterCreate,
39
+ cwd: workspacePath,
40
+ issue,
41
+ workerHost,
42
+ timeoutMs: this.config.hooks.timeoutMs
43
+ });
44
+ }
45
+
46
+ return {
47
+ path: workspacePath,
48
+ workspace_key: workspaceKey,
49
+ worker_host: workerHost,
50
+ created_now: createdNow
51
+ };
52
+ }
53
+
54
+ async runBeforeRunHook(workspacePath, issue, workerHost = null) {
55
+ if (!this.config.hooks.beforeRun) return;
56
+ await runHook({
57
+ command: this.config.hooks.beforeRun,
58
+ cwd: workspacePath,
59
+ issue,
60
+ workerHost,
61
+ timeoutMs: this.config.hooks.timeoutMs
62
+ });
63
+ }
64
+
65
+ async runAfterRunHook(workspacePath, issue, workerHost = null) {
66
+ if (!this.config.hooks.afterRun) return;
67
+
68
+ try {
69
+ await runHook({
70
+ command: this.config.hooks.afterRun,
71
+ cwd: workspacePath,
72
+ issue,
73
+ workerHost,
74
+ timeoutMs: this.config.hooks.timeoutMs
75
+ });
76
+ } catch (error) {
77
+ this.store.appendSystemLog(`after_run hook failed: ${error.message}`);
78
+ }
79
+ }
80
+
81
+ async cleanup(issue) {
82
+ const workerHost = issue.workerHost || null;
83
+ const workspaceKey = issue.workspaceKey || sanitize(issue.identifier || issue.id);
84
+ return this.cleanupByPath(buildWorkspacePath(this.config.workspaceRoot, workspaceKey, workerHost), workerHost);
85
+ }
86
+
87
+ async cleanupByPath(workspacePath, workerHost = null) {
88
+ if (!workspacePath) return false;
89
+
90
+ if (workerHost) {
91
+ const safeRemotePath = assertRemotePathSafe(workspacePath);
92
+ await runBeforeRemoveHook(this.config, safeRemotePath, workerHost);
93
+ await removeRemoteWorkspace(workerHost, safeRemotePath, this.config.hooks.timeoutMs);
94
+ await runAfterRemoveHook(this.config, this.config.workspaceRoot, workerHost);
95
+ this.store.appendSystemLog(`workspace cleaned: ${safeRemotePath}`);
96
+ return true;
97
+ }
98
+
99
+ if (!fs.existsSync(workspacePath)) return false;
100
+
101
+ const safePath = assertPathInsideRoot(workspacePath, this.config.workspaceRoot);
102
+ await runBeforeRemoveHook(this.config, safePath, null);
103
+ fs.rmSync(safePath, { recursive: true, force: true });
104
+ await runAfterRemoveHook(this.config, this.config.workspaceRoot, null);
105
+ this.store.appendSystemLog(`workspace cleaned: ${safePath}`);
106
+ return true;
107
+ }
108
+
109
+ async cleanupIssueWorkspaces(identifier, workerHost = null) {
110
+ if (!identifier) return;
111
+
112
+ if (workerHost) {
113
+ await this.cleanupByPath(buildWorkspacePath(this.config.workspaceRoot, sanitize(identifier), workerHost), workerHost);
114
+ return;
115
+ }
116
+
117
+ await this.cleanupByPath(buildWorkspacePath(this.config.workspaceRoot, sanitize(identifier), null), null);
118
+ for (const host of this.config.worker?.sshHosts || []) {
119
+ await this.cleanupByPath(buildWorkspacePath(this.config.workspaceRoot, sanitize(identifier), host), host);
120
+ }
121
+ }
122
+ }
123
+
124
+ function ensureLocalWorkspace(workspaceRoot, workspacePath) {
125
+ const safePath = assertPathInsideRoot(workspacePath, workspaceRoot);
126
+ const createdNow = !fs.existsSync(safePath);
127
+ fs.mkdirSync(safePath, { recursive: true });
128
+ return createdNow;
129
+ }
130
+
131
+ async function ensureRemoteWorkspace(workerHost, workspacePath, timeoutMs) {
132
+ const safeRemotePath = assertRemotePathSafe(workspacePath);
133
+ const script = [
134
+ 'set -eu',
135
+ `workspace=${shellEscape(safeRemotePath)}`,
136
+ 'if [ -d "$workspace" ]; then',
137
+ ' created=0',
138
+ 'elif [ -e "$workspace" ]; then',
139
+ ' rm -rf "$workspace"',
140
+ ' mkdir -p "$workspace"',
141
+ ' created=1',
142
+ 'else',
143
+ ' mkdir -p "$workspace"',
144
+ ' created=1',
145
+ 'fi',
146
+ 'cd "$workspace"',
147
+ `printf '%s\\t%s\\t%s\\n' '${REMOTE_WORKSPACE_MARKER}' "$created" "$(pwd -P)"`
148
+ ].join('\n');
149
+
150
+ const { stdout } = await runSSHCommand(workerHost, script, { timeoutMs });
151
+ const line = stdout.split('\n').find((entry) => entry.startsWith(`${REMOTE_WORKSPACE_MARKER}\t`));
152
+ if (!line) {
153
+ throw new Error(`Remote workspace create did not return marker for ${workerHost}`);
154
+ }
155
+
156
+ const [, createdValue] = line.split('\t');
157
+ return createdValue === '1';
158
+ }
159
+
160
+ async function syncProjectSnapshotToRemote({ projectRoot, workspacePath, workerHost, ignoreNames }) {
161
+ const tarArgs = ['-C', projectRoot];
162
+ for (const ignoreName of ignoreNames) {
163
+ tarArgs.push('--exclude', ignoreName);
164
+ }
165
+ tarArgs.push('-cf', '-', '.');
166
+
167
+ const tarChild = spawn('tar', tarArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
168
+ const sshChild = startSSHProcess(
169
+ workerHost,
170
+ `mkdir -p ${shellEscape(workspacePath)} && tar -xf - -C ${shellEscape(workspacePath)}`,
171
+ { stdio: ['pipe', 'pipe', 'pipe'] }
172
+ );
173
+
174
+ const tarErrors = [];
175
+ const sshErrors = [];
176
+ tarChild.stderr.on('data', (chunk) => tarErrors.push(String(chunk)));
177
+ sshChild.stderr.on('data', (chunk) => sshErrors.push(String(chunk)));
178
+ tarChild.stdout.pipe(sshChild.stdin);
179
+
180
+ const [tarStatus, sshStatus] = await Promise.all([
181
+ waitForExit(tarChild),
182
+ waitForExit(sshChild)
183
+ ]);
184
+
185
+ if (tarStatus !== 0 || sshStatus !== 0) {
186
+ throw new Error(
187
+ `Remote workspace sync failed: tar=${tarStatus} ssh=${sshStatus} ${tarErrors.join('').trim()} ${sshErrors.join('').trim()}`.trim()
188
+ );
189
+ }
190
+ }
191
+
192
+ async function removeRemoteWorkspace(workerHost, workspacePath, timeoutMs) {
193
+ await runSSHCommand(workerHost, `rm -rf ${shellEscape(workspacePath)}`, { timeoutMs });
194
+ }
195
+
196
+ async function runBeforeRemoveHook(config, workspacePath, workerHost) {
197
+ if (!config.hooks.beforeRemove) return;
198
+ await runHook({
199
+ command: config.hooks.beforeRemove,
200
+ cwd: workspacePath,
201
+ issue: null,
202
+ workerHost,
203
+ timeoutMs: config.hooks.timeoutMs
204
+ });
205
+ }
206
+
207
+ async function runAfterRemoveHook(config, workspaceRoot, workerHost) {
208
+ if (!config.hooks.afterRemove) return;
209
+ await runHook({
210
+ command: config.hooks.afterRemove,
211
+ cwd: workspaceRoot,
212
+ issue: null,
213
+ workerHost,
214
+ timeoutMs: config.hooks.timeoutMs
215
+ });
216
+ }
217
+
218
+ function copyProjectSnapshot(sourceRoot, targetRoot, ignoreNames) {
219
+ for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
220
+ if (ignoreNames.has(entry.name)) continue;
221
+
222
+ const sourcePath = path.join(sourceRoot, entry.name);
223
+ const targetPath = path.join(targetRoot, entry.name);
224
+
225
+ if (entry.isDirectory()) {
226
+ fs.mkdirSync(targetPath, { recursive: true });
227
+ copyProjectSnapshot(sourcePath, targetPath, ignoreNames);
228
+ continue;
229
+ }
230
+
231
+ if (entry.isSymbolicLink()) {
232
+ fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath);
233
+ continue;
234
+ }
235
+
236
+ fs.copyFileSync(sourcePath, targetPath);
237
+ }
238
+ }
239
+
240
+ function buildWorkspacePath(workspaceRoot, workspaceKey, workerHost) {
241
+ if (workerHost) {
242
+ return path.posix.join(workspaceRoot, workspaceKey);
243
+ }
244
+ return path.join(workspaceRoot, workspaceKey);
245
+ }
246
+
247
+ function sanitize(value) {
248
+ return String(value).replace(/[^a-zA-Z0-9._-]/g, '_');
249
+ }
250
+
251
+ function runHook({ command, cwd, issue, workerHost, timeoutMs }) {
252
+ return workerHost
253
+ ? runRemoteHook({ command, cwd, issue, workerHost, timeoutMs })
254
+ : runLocalHook({ command, cwd, issue, timeoutMs });
255
+ }
256
+
257
+ function runLocalHook({ command, cwd, issue, timeoutMs }) {
258
+ return new Promise((resolve, reject) => {
259
+ exec(command, { cwd, shell: '/bin/bash', timeout: timeoutMs, env: hookEnv(issue) }, (error) => {
260
+ if (error) return reject(error);
261
+ return resolve();
262
+ });
263
+ });
264
+ }
265
+
266
+ async function runRemoteHook({ command, cwd, issue, workerHost, timeoutMs }) {
267
+ const envExports = Object.entries(hookEnv(issue))
268
+ .filter(([key]) => key.startsWith('TASKODE_'))
269
+ .map(([key, value]) => `export ${key}=${shellEscape(value)}`)
270
+ .join('\n');
271
+
272
+ const script = [envExports, `cd ${shellEscape(cwd)}`, command].filter(Boolean).join('\n');
273
+ await runSSHCommand(workerHost, script, { timeoutMs });
274
+ }
275
+
276
+ function hookEnv(issue) {
277
+ const env = { ...process.env };
278
+ if (!issue) return env;
279
+ env.TASKODE_ISSUE_ID = String(issue.id || '');
280
+ env.TASKODE_ISSUE_IDENTIFIER = String(issue.identifier || issue.id || '');
281
+ env.TASKODE_ISSUE_TITLE = String(issue.title || '');
282
+ env.TASKODE_ISSUE_STATE = String(issue.state || '');
283
+ return env;
284
+ }
285
+
286
+ function waitForExit(child) {
287
+ return new Promise((resolve, reject) => {
288
+ child.on('error', reject);
289
+ child.on('close', (code) => resolve(code ?? 0));
290
+ });
291
+ }