@ran-sh/dsh-crew 0.3.8 → 0.4.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.
@@ -18,6 +18,7 @@ import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs
18
18
  import { buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
19
19
  import { parseDeliveryReport } from './delivery.mjs';
20
20
  import { classifyFailure } from './failure-classification.mjs';
21
+ import { createCanonicalJobEvent } from './job-contracts.mjs';
21
22
 
22
23
  export const WORKFLOW_ERROR_CODES = {
23
24
  ISOLATION_UNAVAILABLE: 'ISOLATION_UNAVAILABLE',
@@ -111,6 +112,19 @@ export function createWorkflowRuntime(adapters, {
111
112
  const queue = [];
112
113
  let active = 0;
113
114
  let maxParallelLimit = normalizeMaxParallel(maxParallel);
115
+ let nestedIdFactory = null;
116
+
117
+ function nextWorkflowId() {
118
+ if (nestedIdFactory) return nestedIdFactory();
119
+ const generated = idFactory();
120
+ // A few embedders provide a factory-builder for deterministic tests.
121
+ // Normalize that legacy shape once while keeping the public id a string.
122
+ if (typeof generated === 'function') {
123
+ nestedIdFactory = generated;
124
+ return nestedIdFactory();
125
+ }
126
+ return generated;
127
+ }
114
128
 
115
129
  function log(msg) { logger?.debug?.(`workflow: ${msg}`); }
116
130
 
@@ -140,16 +154,38 @@ export function createWorkflowRuntime(adapters, {
140
154
  log(`${job.id} ${to}`);
141
155
  }
142
156
 
157
+ function recordCanonical(job, type, data = {}, attempt = null, at = clock()) {
158
+ job.canonical_event_sequence += 1;
159
+ job.canonical_events.push(createCanonicalJobEvent({
160
+ jobId: job.id,
161
+ type,
162
+ sequence: job.canonical_event_sequence,
163
+ at,
164
+ role: job.role,
165
+ attempt,
166
+ data,
167
+ }));
168
+ }
169
+
143
170
  function createJob(spec) {
144
171
  const now = clock();
145
- return {
146
- id: idFactory(),
172
+ const job = {
173
+ id: nextWorkflowId(),
174
+ client_job_id: spec.client_job_id ?? null,
147
175
  role: spec.role ?? 'worker',
148
176
  delivery: spec.delivery === 'review' ? 'review' : 'coding',
149
177
  model_class_hint: spec.model_class_hint === 'pro' ? 'pro' : spec.model_class_hint === 'flash' ? 'flash' : null,
150
178
  original_task: spec.task,
151
179
  source: spec.source ?? 'api',
152
180
  requested_cwd: spec.cwd,
181
+ requested_isolation: spec.requested_isolation ?? null,
182
+ workspace_branch: spec.workspace_branch ?? null,
183
+ timeout_seconds: spec.timeout_seconds ?? null,
184
+ profile_id: spec.profile_id ?? null,
185
+ allow_fallback: spec.allow_fallback !== false,
186
+ routing: spec.routing ?? 'auto',
187
+ review_strictness: spec.review_strictness ?? null,
188
+ workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
153
189
  effort: spec.effort ?? 'max',
154
190
  phase: JOB_PHASES.CREATED,
155
191
  status: 'running',
@@ -170,12 +206,16 @@ export function createWorkflowRuntime(adapters, {
170
206
  retain_workspace: false,
171
207
  workspace_retained: false,
172
208
  events: [{ at: now, phase: JOB_PHASES.CREATED, type: 'created' }],
209
+ canonical_events: [],
210
+ canonical_event_sequence: 0,
173
211
  createdAt: now,
174
212
  startedAt: now,
175
213
  endedAt: null,
176
214
  workspaceHandle: null,
177
215
  waiters: [],
178
216
  };
217
+ recordCanonical(job, 'job.created', { client_job_id: job.client_job_id }, null, now);
218
+ return job;
179
219
  }
180
220
 
181
221
  function releaseSlot() {
@@ -227,6 +267,9 @@ export function createWorkflowRuntime(adapters, {
227
267
  job.error = err?.message ?? String(err);
228
268
  job.error_code = err?.code ?? err?.policyCode ?? job.error_code ?? null;
229
269
  job.phase = JOB_PHASES.FAILED;
270
+ recordCanonical(job, 'job.failed', {
271
+ error_code: job.error_code,
272
+ });
230
273
  setTerminal(job);
231
274
  }
232
275
 
@@ -259,10 +302,7 @@ export function createWorkflowRuntime(adapters, {
259
302
  config = adapters.getConfig?.() ?? {};
260
303
  const alloc = await adapters.allocateWorkspace?.(job);
261
304
  if (alloc && alloc.ok === false) {
262
- job.error = alloc.error ?? alloc.reason;
263
- job.phase = JOB_PHASES.FAILED;
264
- if (alloc.reason) job.error_code = alloc.reason;
265
- setTerminal(job);
305
+ failJob(job, Object.assign(new Error(alloc.error ?? alloc.reason), { code: alloc.reason }));
266
306
  return;
267
307
  }
268
308
  if (alloc && alloc.ok) {
@@ -272,12 +312,16 @@ export function createWorkflowRuntime(adapters, {
272
312
  job.base_revision = alloc.base_revision ?? null;
273
313
  job.primary_workspace_dirty = alloc.primary_workspace_dirty === true;
274
314
  }
315
+ recordCanonical(job, 'job.started', {
316
+ isolation: job.isolation,
317
+ base_revision: job.base_revision,
318
+ });
275
319
  transition(job, JOB_PHASES.RUNNING, 'start');
276
320
 
277
321
  if (isReviewJob) {
278
322
  transition(job, JOB_PHASES.REVIEWING, 'explicit reviewer');
279
323
  const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
280
- const reviewTask = adapters.buildReviewTask(job.original_task, null);
324
+ const reviewTask = adapters.buildReviewTask(job.original_task, null, { strictness: job.review_strictness ?? 'standard' });
281
325
  const review = await runReviewerAttempt(job, reviewTask, config, before);
282
326
  job.review = review;
283
327
  if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
@@ -290,10 +334,20 @@ export function createWorkflowRuntime(adapters, {
290
334
  for (;;) {
291
335
  if (job.cancelling) { cancelWorkflow(job); return; }
292
336
  if (attempt > 0) transition(job, JOB_PHASES.RUNNING, `escalated attempt ${attempt}`);
293
- const policy = resolveModelPolicy(config, 'worker', { attempt });
337
+ const resolvedPolicy = resolveModelPolicy(config, 'worker', { attempt });
338
+ const fallbackPolicy = job.allow_fallback === false
339
+ ? { ...resolvedPolicy, escalation: { ...resolvedPolicy.escalation, enabled: false } }
340
+ : resolvedPolicy;
341
+ const policy = job.routing === 'stable'
342
+ ? { ...fallbackPolicy, adaptive: { ...fallbackPolicy.adaptive, enabled: false } }
343
+ : fallbackPolicy;
294
344
  const attemptId = attemptIdFor(adapters, job.id, attempt === 0 ? '' : String(attempt));
295
345
  job.current_attempt_id = attemptId;
296
346
  job.events.push({ at: clock(), phase: job.phase, type: 'attempt/start', attempt, escalation_reason: escalationReason });
347
+ recordCanonical(job, 'worker.started', {
348
+ attempt_id: attemptId,
349
+ escalation_reason: escalationReason,
350
+ }, attempt);
297
351
  const ar = await adapters.executeAttempt({
298
352
  id: attemptId,
299
353
  workflowId: job.id,
@@ -302,6 +356,7 @@ export function createWorkflowRuntime(adapters, {
302
356
  task: job.original_task,
303
357
  cwd: job.execution_cwd,
304
358
  effort: job.effort,
359
+ timeout_seconds: job.timeout_seconds,
305
360
  policy,
306
361
  source: job.source,
307
362
  model_class_hint: job.model_class_hint,
@@ -314,6 +369,18 @@ export function createWorkflowRuntime(adapters, {
314
369
  if (job.cancelling) { cancelWorkflow(job); return; }
315
370
  const attemptView = attemptRecord(ar, attempt);
316
371
  job.attempts.push(attemptView);
372
+ recordCanonical(job, 'model.selected', {
373
+ provider: attemptView.provider,
374
+ model: attemptView.model,
375
+ source: attemptView.selection_source,
376
+ fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
377
+ }, attempt);
378
+ recordCanonical(job, 'worker.completed', {
379
+ attempt_id: attemptView.id,
380
+ status: attemptView.status,
381
+ stop_reason: attemptView.stopReason,
382
+ error_code: attemptView.error_code,
383
+ }, attempt);
317
384
  if (ar.infra === true) {
318
385
  failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
319
386
  return;
@@ -360,6 +427,11 @@ export function createWorkflowRuntime(adapters, {
360
427
  return;
361
428
  }
362
429
  if (decision.step === 'escalate') {
430
+ recordCanonical(job, 'model.fallback', {
431
+ reason: decision.reason,
432
+ from_attempt: attempt,
433
+ to_attempt: attempt + 1,
434
+ }, attempt);
363
435
  transition(job, JOB_PHASES.ESCALATING, decision.reason);
364
436
  escalationReason = decision.reason;
365
437
  attempt += 1;
@@ -368,7 +440,7 @@ export function createWorkflowRuntime(adapters, {
368
440
  if (decision.step === 'review') {
369
441
  transition(job, JOB_PHASES.REVIEWING, 'automatic review');
370
442
  const before = job.candidate;
371
- const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null });
443
+ const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null }, { strictness: job.review_strictness ?? 'standard' });
372
444
  const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
373
445
  job.review = review;
374
446
  transition(job, JOB_PHASES.READY, decision.reason);
@@ -401,6 +473,7 @@ export function createWorkflowRuntime(adapters, {
401
473
  const attemptId = attemptIdFor(adapters, job.id, 'review');
402
474
  job.current_attempt_id = attemptId;
403
475
  job.events.push({ at: clock(), phase: job.phase, type: 'review/start' });
476
+ recordCanonical(job, 'review.started', { attempt_id: attemptId }, 0);
404
477
  const ar = await adapters.executeAttempt({
405
478
  id: attemptId,
406
479
  workflowId: job.id,
@@ -420,13 +493,30 @@ export function createWorkflowRuntime(adapters, {
420
493
  job.current_attempt_id = null;
421
494
  const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
422
495
  job.attempts.push(attemptView);
496
+ recordCanonical(job, 'model.selected', {
497
+ provider: attemptView.provider,
498
+ model: attemptView.model,
499
+ source: attemptView.selection_source,
500
+ fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
501
+ }, 0);
423
502
  if (ar.status === 'failed' && attemptView.error_code) job.error_code = attemptView.error_code;
424
503
  const afterCandidate = job.isolation === 'worktree' ? await safeCapture(adapters, cwd, baseRevision) : null;
425
- return normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
504
+ const review = normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
505
+ recordCanonical(job, 'review.completed', {
506
+ attempt_id: attemptView.id,
507
+ status: review.status,
508
+ verdict: review.verdict,
509
+ error_code: attemptView.error_code,
510
+ }, 0);
511
+ return review;
426
512
  }
427
513
 
428
514
  function finalize(job) {
429
515
  transition(job, JOB_PHASES.COMPLETED, job.review ? 'reviewed' : 'verified');
516
+ recordCanonical(job, 'job.completed', {
517
+ reviewed: !!job.review,
518
+ task_status: job.outcome?.task_status ?? null,
519
+ });
430
520
  setTerminal(job);
431
521
  }
432
522
 
@@ -464,6 +554,7 @@ export function createWorkflowRuntime(adapters, {
464
554
  if (activeAttempt) adapters.cancelAttempt?.(activeAttempt).catch(() => {});
465
555
  job.phase = JOB_PHASES.CANCELLED;
466
556
  job.status = 'cancelled';
557
+ recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
467
558
  job.endedAt = clock();
468
559
  job.current_attempt_id = null;
469
560
  for (const w of job.waiters.splice(0)) w();
@@ -481,6 +572,7 @@ export function createWorkflowRuntime(adapters, {
481
572
  });
482
573
  const v = {
483
574
  id: job.id,
575
+ client_job_id: job.client_job_id,
484
576
  role: job.role,
485
577
  phase: job.phase,
486
578
  status: job.status,
@@ -488,6 +580,11 @@ export function createWorkflowRuntime(adapters, {
488
580
  current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
489
581
  model_class_hint: job.model_class_hint,
490
582
  source: job.source,
583
+ profile_id: job.profile_id,
584
+ routing: job.routing,
585
+ workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
586
+ workspace_branch: job.workspace_branch,
587
+ timeout_seconds: job.timeout_seconds,
491
588
  requested_cwd: job.requested_cwd,
492
589
  execution_cwd: job.execution_cwd,
493
590
  isolation: job.isolation,
@@ -505,6 +602,7 @@ export function createWorkflowRuntime(adapters, {
505
602
  decision: job.decision,
506
603
  candidate_available: !!job.candidate,
507
604
  review_status: job.review ? job.review.status ?? null : null,
605
+ event_cursor: job.canonical_events.at(-1)?.sequence ?? 0,
508
606
  };
509
607
  if (withResult) {
510
608
  v.child_attempts = job.attempts.map((a) => ({
@@ -520,6 +618,7 @@ export function createWorkflowRuntime(adapters, {
520
618
  v.candidate = job.candidate;
521
619
  v.review = job.review;
522
620
  v.events = job.events;
621
+ v.canonical_events = job.canonical_events.map((event) => ({ ...event, data: { ...event.data } }));
523
622
  }
524
623
  return v;
525
624
  }
@@ -0,0 +1,146 @@
1
+ // Workspace Context carries stable project facts by reference. Instruction
2
+ // file contents and validation output remain in the workspace and are never
3
+ // copied into this registry or across Agent hand-offs.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
+
9
+ export const WORKSPACE_CONTEXT_SCHEMA_VERSION = 1;
10
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
11
+
12
+ export function isSafeBranchName(value) {
13
+ if (typeof value !== 'string' || value.length < 1 || value.length > 256) return false;
14
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value)) return false;
15
+ if (value.includes('..') || value.includes('@{') || value.includes('//')) return false;
16
+ if (value.endsWith('/') || value.endsWith('.') || value.endsWith('.lock')) return false;
17
+ return value.split('/').every((part) => part && !part.startsWith('.'));
18
+ }
19
+
20
+ function boundedStrings(value, { maxItems = 32, maxLength = 256 } = {}) {
21
+ if (value === undefined) return [];
22
+ if (!Array.isArray(value)) return null;
23
+ const result = [];
24
+ for (const item of value) {
25
+ if (typeof item !== 'string' || item.trim() === '' || item.length > maxLength) return null;
26
+ result.push(item.trim());
27
+ if (result.length > maxItems) return null;
28
+ }
29
+ return [...new Set(result)];
30
+ }
31
+
32
+ function safeReference(value) {
33
+ if (isAbsolute(value)) return false;
34
+ const normalized = value.replace(/\\/g, '/');
35
+ return normalized !== '..' && !normalized.startsWith('../') && !normalized.includes('/../');
36
+ }
37
+
38
+ function normalizeContext(id, raw) {
39
+ if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
40
+ if (typeof raw.repo_root !== 'string' || !isAbsolute(raw.repo_root)) return null;
41
+ const instructionFiles = boundedStrings(raw.instruction_files);
42
+ const validationHints = boundedStrings(raw.validation_hints, { maxItems: 32, maxLength: 512 });
43
+ if (!instructionFiles || !validationHints || instructionFiles.some((entry) => !safeReference(entry))) return null;
44
+ if (raw.default_branch != null && !isSafeBranchName(raw.default_branch)) return null;
45
+ return {
46
+ workspace_id: id,
47
+ repo_root: resolve(raw.repo_root),
48
+ default_branch: raw.default_branch?.trim() || null,
49
+ instruction_files: instructionFiles,
50
+ validation_hints: validationHints,
51
+ };
52
+ }
53
+
54
+ export function workspaceContextsFile({ home = homedir() } = {}) {
55
+ return join(home, '.config', 'dsh-crew', 'workspaces.json');
56
+ }
57
+
58
+ export function loadWorkspaceContexts({ home = homedir(), file = workspaceContextsFile({ home }) } = {}) {
59
+ if (!existsSync(file)) {
60
+ return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: true, source: 'none', contexts: {}, errors: [] };
61
+ }
62
+ let raw;
63
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
64
+ return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: false, source: 'file', contexts: {}, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_INVALID' }] };
65
+ }
66
+ return parseWorkspaceContexts(raw);
67
+ }
68
+
69
+ function parseWorkspaceContexts(raw) {
70
+ if (raw?.schema_version !== WORKSPACE_CONTEXT_SCHEMA_VERSION || !raw.workspaces || typeof raw.workspaces !== 'object' || Array.isArray(raw.workspaces)) {
71
+ return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: false, source: 'file', contexts: {}, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_INVALID' }] };
72
+ }
73
+ const contexts = {};
74
+ const errors = [];
75
+ for (const [id, value] of Object.entries(raw.workspaces)) {
76
+ const context = normalizeContext(id, value);
77
+ if (!context) errors.push({ code: 'WORKSPACE_CONTEXT_INVALID', workspace_id: ID.test(id) ? id : '<invalid>' });
78
+ else contexts[id] = context;
79
+ }
80
+ return { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', contexts, errors: errors.slice(0, 32) };
81
+ }
82
+
83
+ export function saveWorkspaceContexts(document, { home = homedir(), file = workspaceContextsFile({ home }) } = {}) {
84
+ const parsed = parseWorkspaceContexts(document);
85
+ if (!parsed.ok) return parsed;
86
+ const payload = { schema_version: WORKSPACE_CONTEXT_SCHEMA_VERSION, workspaces: parsed.contexts };
87
+ mkdirSync(dirname(file), { recursive: true });
88
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
89
+ try {
90
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
91
+ renameSync(temp, file);
92
+ } catch {
93
+ rmSync(temp, { force: true });
94
+ return { ...parsed, ok: false, errors: [{ code: 'WORKSPACE_CONTEXT_FILE_WRITE_FAILED' }], error_code: 'WORKSPACE_CONTEXT_FILE_WRITE_FAILED' };
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ function contains(root, target) {
100
+ const rel = relative(resolve(root), resolve(target));
101
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..\\`) && !rel.startsWith('../') && !isAbsolute(rel));
102
+ }
103
+
104
+ export function resolveWorkspaceContext(registry, { workspace_id: workspaceId, cwd } = {}) {
105
+ if (!workspaceId) return { ok: true, context: null };
106
+ const context = registry?.contexts?.[workspaceId];
107
+ if (!context) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', workspace_id: workspaceId };
108
+ if (cwd && !contains(context.repo_root, cwd)) {
109
+ return { ok: false, code: 'WORKSPACE_ROOT_MISMATCH', workspace_id: workspaceId };
110
+ }
111
+ return { ok: true, context: { ...context, instruction_files: [...context.instruction_files], validation_hints: [...context.validation_hints] } };
112
+ }
113
+
114
+ export function buildWorkspaceTask(objective, context) {
115
+ const task = String(objective ?? '').slice(0, 32_768);
116
+ if (!context) return task;
117
+ const lines = [
118
+ '[DSH Workspace Context — references only]',
119
+ `Workspace: ${context.workspace_id}`,
120
+ `Repository root: ${context.repo_root}`,
121
+ ];
122
+ if (context.default_branch) lines.push(`Default branch: ${context.default_branch}`);
123
+ if (context.instruction_files.length) lines.push(`Instruction references: ${context.instruction_files.join(', ')}`);
124
+ if (context.validation_hints.length) lines.push(`Validation hints: ${context.validation_hints.join(' | ')}`);
125
+ lines.push('Open referenced files in the workspace when needed; do not expect their contents in this hand-off.', '', '[Delegated objective]', task);
126
+ return lines.join('\n');
127
+ }
128
+
129
+ export function addContextReferences(context, references, { cwd } = {}) {
130
+ const refs = boundedStrings(references);
131
+ if (refs === null || refs.some((entry) => !safeReference(entry))) {
132
+ return { ok: false, code: 'WORKSPACE_CONTEXT_REFS_INVALID' };
133
+ }
134
+ if (refs.length === 0) return { ok: true, context };
135
+ const base = context ?? {
136
+ workspace_id: null,
137
+ repo_root: resolve(cwd ?? process.cwd()),
138
+ default_branch: null,
139
+ instruction_files: [],
140
+ validation_hints: [],
141
+ };
142
+ return {
143
+ ok: true,
144
+ context: { ...base, instruction_files: [...new Set([...base.instruction_files, ...refs])] },
145
+ };
146
+ }
@@ -0,0 +1,32 @@
1
+ // Read-only workspace preflight used by MCP and local HTTP readiness. It never
2
+ // changes branches, creates worktrees, or touches user files.
3
+
4
+ import { constants } from 'node:fs';
5
+ import { access as fsAccess } from 'node:fs/promises';
6
+ import { inspectRepository } from './workspace-isolation.mjs';
7
+
8
+ export async function assessWorkspaceReadiness({ cwd, inspect = inspectRepository, access = fsAccess } = {}) {
9
+ if (!cwd) return { ok: true, status: 'READY', reason_code: 'WORKSPACE_NOT_REQUESTED', repo_root: null, base_revision: null };
10
+ const repository = await inspect({ cwd });
11
+ if (!repository?.ok) {
12
+ return { ok: false, status: 'UNAVAILABLE', reason_code: repository?.reason ?? 'WORKSPACE_UNAVAILABLE' };
13
+ }
14
+ try {
15
+ await access(repository.repoRoot, constants.W_OK);
16
+ } catch {
17
+ return {
18
+ ok: true, status: 'READ_ONLY', reason_code: 'WORKSPACE_READ_ONLY',
19
+ repo_root: repository.repoRoot, base_revision: repository.baseRevision,
20
+ };
21
+ }
22
+ if (repository.dirty === true) {
23
+ return {
24
+ ok: true, status: 'CONFLICT', reason_code: 'WORKSPACE_DIRTY',
25
+ repo_root: repository.repoRoot, base_revision: repository.baseRevision,
26
+ };
27
+ }
28
+ return {
29
+ ok: true, status: 'READY', reason_code: 'WORKSPACE_READY',
30
+ repo_root: repository.repoRoot, base_revision: repository.baseRevision,
31
+ };
32
+ }