@helmiq/crew 0.1.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.
- package/defaults/personas/architect.persona.yaml +72 -0
- package/defaults/personas/engineer.persona.yaml +137 -0
- package/defaults/personas/persona-spec.schema.yaml +149 -0
- package/defaults/personas/reviewer.persona.yaml +47 -0
- package/defaults/rubrics/adr.rubric.yaml +48 -0
- package/defaults/rubrics/code-review.rubric.yaml +39 -0
- package/defaults/rubrics/pull-request.rubric.yaml +40 -0
- package/dist/actions/actions.test.d.ts +2 -0
- package/dist/actions/actions.test.d.ts.map +1 -0
- package/dist/actions/actions.test.js +158 -0
- package/dist/actions/direct-dispatcher.d.ts +10 -0
- package/dist/actions/direct-dispatcher.d.ts.map +1 -0
- package/dist/actions/direct-dispatcher.js +27 -0
- package/dist/actions/dispatcher.d.ts +11 -0
- package/dist/actions/dispatcher.d.ts.map +1 -0
- package/dist/actions/dispatcher.js +1 -0
- package/dist/actions/index.d.ts +7 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +3 -0
- package/dist/actions/registry.d.ts +13 -0
- package/dist/actions/registry.d.ts.map +1 -0
- package/dist/actions/registry.js +40 -0
- package/dist/actions/resolver.d.ts +47 -0
- package/dist/actions/resolver.d.ts.map +1 -0
- package/dist/actions/resolver.js +43 -0
- package/dist/cli/cli.test.d.ts +2 -0
- package/dist/cli/cli.test.d.ts.map +1 -0
- package/dist/cli/cli.test.js +392 -0
- package/dist/cli/run.d.ts +45 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +236 -0
- package/dist/common/errors.d.ts +76 -0
- package/dist/common/errors.d.ts.map +1 -0
- package/dist/common/errors.js +74 -0
- package/dist/config/config.test.d.ts +2 -0
- package/dist/config/config.test.d.ts.map +1 -0
- package/dist/config/config.test.js +691 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +4 -0
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +56 -0
- package/dist/config/model-resolver.d.ts +24 -0
- package/dist/config/model-resolver.d.ts.map +1 -0
- package/dist/config/model-resolver.js +39 -0
- package/dist/config/resolver.d.ts +22 -0
- package/dist/config/resolver.d.ts.map +1 -0
- package/dist/config/resolver.js +115 -0
- package/dist/config/schemas.d.ts +266 -0
- package/dist/config/schemas.d.ts.map +1 -0
- package/dist/config/schemas.js +115 -0
- package/dist/context/artifact-reader.d.ts +12 -0
- package/dist/context/artifact-reader.d.ts.map +1 -0
- package/dist/context/artifact-reader.js +92 -0
- package/dist/context/assembler.d.ts +22 -0
- package/dist/context/assembler.d.ts.map +1 -0
- package/dist/context/assembler.js +126 -0
- package/dist/context/code-reader.d.ts +14 -0
- package/dist/context/code-reader.d.ts.map +1 -0
- package/dist/context/code-reader.js +56 -0
- package/dist/context/context.test.d.ts +2 -0
- package/dist/context/context.test.d.ts.map +1 -0
- package/dist/context/context.test.js +260 -0
- package/dist/context/index.d.ts +9 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +5 -0
- package/dist/context/section-extractor.d.ts +9 -0
- package/dist/context/section-extractor.d.ts.map +1 -0
- package/dist/context/section-extractor.js +32 -0
- package/dist/context/token-budget.d.ts +11 -0
- package/dist/context/token-budget.d.ts.map +1 -0
- package/dist/context/token-budget.js +22 -0
- package/dist/control/control.test.d.ts +2 -0
- package/dist/control/control.test.d.ts.map +1 -0
- package/dist/control/control.test.js +137 -0
- package/dist/control/id-generator.d.ts +12 -0
- package/dist/control/id-generator.d.ts.map +1 -0
- package/dist/control/id-generator.js +20 -0
- package/dist/control/index.d.ts +5 -0
- package/dist/control/index.d.ts.map +1 -0
- package/dist/control/index.js +3 -0
- package/dist/control/lock-manager.d.ts +13 -0
- package/dist/control/lock-manager.d.ts.map +1 -0
- package/dist/control/lock-manager.js +72 -0
- package/dist/control/run-state.d.ts +16 -0
- package/dist/control/run-state.d.ts.map +1 -0
- package/dist/control/run-state.js +55 -0
- package/dist/engine/composite.d.ts +34 -0
- package/dist/engine/composite.d.ts.map +1 -0
- package/dist/engine/composite.js +192 -0
- package/dist/engine/composite.test.d.ts +2 -0
- package/dist/engine/composite.test.d.ts.map +1 -0
- package/dist/engine/composite.test.js +1947 -0
- package/dist/engine/engine.test.d.ts +2 -0
- package/dist/engine/engine.test.d.ts.map +1 -0
- package/dist/engine/engine.test.js +334 -0
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +5 -0
- package/dist/engine/llm-client.d.ts +27 -0
- package/dist/engine/llm-client.d.ts.map +1 -0
- package/dist/engine/llm-client.js +46 -0
- package/dist/engine/simple.d.ts +21 -0
- package/dist/engine/simple.d.ts.map +1 -0
- package/dist/engine/simple.js +59 -0
- package/dist/engine/tool-dispatch.d.ts +37 -0
- package/dist/engine/tool-dispatch.d.ts.map +1 -0
- package/dist/engine/tool-dispatch.js +146 -0
- package/dist/engine/tool-dispatch.test.d.ts +2 -0
- package/dist/engine/tool-dispatch.test.d.ts.map +1 -0
- package/dist/engine/tool-dispatch.test.js +348 -0
- package/dist/engine/tool-filter.d.ts +13 -0
- package/dist/engine/tool-filter.d.ts.map +1 -0
- package/dist/engine/tool-filter.js +25 -0
- package/dist/evaluation/evaluation.test.d.ts +2 -0
- package/dist/evaluation/evaluation.test.d.ts.map +1 -0
- package/dist/evaluation/evaluation.test.js +490 -0
- package/dist/evaluation/evaluator.d.ts +19 -0
- package/dist/evaluation/evaluator.d.ts.map +1 -0
- package/dist/evaluation/evaluator.js +78 -0
- package/dist/evaluation/index.d.ts +4 -0
- package/dist/evaluation/index.d.ts.map +1 -0
- package/dist/evaluation/index.js +2 -0
- package/dist/evaluation/scorer.d.ts +38 -0
- package/dist/evaluation/scorer.d.ts.map +1 -0
- package/dist/evaluation/scorer.js +94 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider-factory.d.ts +11 -0
- package/dist/providers/provider-factory.d.ts.map +1 -0
- package/dist/providers/provider-factory.js +30 -0
- package/dist/publication/frontmatter.d.ts +21 -0
- package/dist/publication/frontmatter.d.ts.map +1 -0
- package/dist/publication/frontmatter.js +15 -0
- package/dist/publication/git-ops.d.ts +18 -0
- package/dist/publication/git-ops.d.ts.map +1 -0
- package/dist/publication/git-ops.js +74 -0
- package/dist/publication/index.d.ts +9 -0
- package/dist/publication/index.d.ts.map +1 -0
- package/dist/publication/index.js +5 -0
- package/dist/publication/provenance-writer.d.ts +27 -0
- package/dist/publication/provenance-writer.d.ts.map +1 -0
- package/dist/publication/provenance-writer.js +21 -0
- package/dist/publication/publication.test.d.ts +2 -0
- package/dist/publication/publication.test.d.ts.map +1 -0
- package/dist/publication/publication.test.js +235 -0
- package/dist/publication/publisher.d.ts +32 -0
- package/dist/publication/publisher.d.ts.map +1 -0
- package/dist/publication/publisher.js +113 -0
- package/dist/publication/secret-scanner.d.ts +6 -0
- package/dist/publication/secret-scanner.d.ts.map +1 -0
- package/dist/publication/secret-scanner.js +19 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/registry.d.ts +15 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +288 -0
- package/dist/tools/registry.test.d.ts +2 -0
- package/dist/tools/registry.test.d.ts.map +1 -0
- package/dist/tools/registry.test.js +131 -0
- package/dist/tools/tool-groups.d.ts +20 -0
- package/dist/tools/tool-groups.d.ts.map +1 -0
- package/dist/tools/tool-groups.js +48 -0
- package/dist/tools/tool-groups.test.d.ts +2 -0
- package/dist/tools/tool-groups.test.d.ts.map +1 -0
- package/dist/tools/tool-groups.test.js +127 -0
- package/dist/types/artifact-store.d.ts +33 -0
- package/dist/types/artifact-store.d.ts.map +1 -0
- package/dist/types/artifact-store.js +9 -0
- package/dist/types/evaluation-rubric.d.ts +18 -0
- package/dist/types/evaluation-rubric.d.ts.map +1 -0
- package/dist/types/evaluation-rubric.js +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/llm-provider.d.ts +47 -0
- package/dist/types/llm-provider.d.ts.map +1 -0
- package/dist/types/llm-provider.js +8 -0
- package/dist/types/persona-spec.d.ts +79 -0
- package/dist/types/persona-spec.d.ts.map +1 -0
- package/dist/types/persona-spec.js +1 -0
- package/dist/types/project-config.d.ts +28 -0
- package/dist/types/project-config.d.ts.map +1 -0
- package/dist/types/project-config.js +1 -0
- package/dist/types/provenance.d.ts +67 -0
- package/dist/types/provenance.d.ts.map +1 -0
- package/dist/types/provenance.js +1 -0
- package/dist/types/run-state.d.ts +11 -0
- package/dist/types/run-state.d.ts.map +1 -0
- package/dist/types/run-state.js +1 -0
- package/dist/types/tool-runtime.d.ts +43 -0
- package/dist/types/tool-runtime.d.ts.map +1 -0
- package/dist/types/tool-runtime.js +30 -0
- package/dist/workspace/detect.d.ts +11 -0
- package/dist/workspace/detect.d.ts.map +1 -0
- package/dist/workspace/detect.js +28 -0
- package/dist/workspace/detect.test.d.ts +2 -0
- package/dist/workspace/detect.test.d.ts.map +1 -0
- package/dist/workspace/detect.test.js +53 -0
- package/dist/workspace/index.d.ts +2 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { assembleContext } from './assembler.js';
|
|
5
|
+
import { extractSections } from './section-extractor.js';
|
|
6
|
+
import { readArtifact } from './artifact-reader.js';
|
|
7
|
+
import { readCodeFiles } from './code-reader.js';
|
|
8
|
+
import { countTokens } from './token-budget.js';
|
|
9
|
+
const runtimeRoot = join(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
10
|
+
const fixturesPath = join(runtimeRoot, 'test', 'fixtures');
|
|
11
|
+
const testConfig = {
|
|
12
|
+
project: {
|
|
13
|
+
name: 'Test Project',
|
|
14
|
+
key: 'TEST',
|
|
15
|
+
},
|
|
16
|
+
workspace: {
|
|
17
|
+
path: '.',
|
|
18
|
+
work: 'work/{EPIC_ID}/',
|
|
19
|
+
runs: 'runs/',
|
|
20
|
+
},
|
|
21
|
+
source: {
|
|
22
|
+
repo: 'github:test/project',
|
|
23
|
+
path: join(runtimeRoot, 'test', 'fixtures-target'),
|
|
24
|
+
},
|
|
25
|
+
llm: {
|
|
26
|
+
default_model: 'claude-sonnet',
|
|
27
|
+
providers: {
|
|
28
|
+
anthropic: {
|
|
29
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
30
|
+
models: { 'claude-sonnet': 'claude-sonnet-4-20250514' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
describe('T-01-002a: artifact reading by type/scope', () => {
|
|
36
|
+
it('reads a requirements artifact by epic scope', async () => {
|
|
37
|
+
const result = await readArtifact({ artifact: 'requirements', scope: { epic: 'CREW-01' } }, testConfig, fixturesPath);
|
|
38
|
+
expect(result.found).toBe(true);
|
|
39
|
+
expect(result.content).toContain('CREW-01 delivers the execution runtime');
|
|
40
|
+
});
|
|
41
|
+
it('reads a design artifact by epic scope', async () => {
|
|
42
|
+
const result = await readArtifact({ artifact: 'design', scope: { epic: 'CREW-01' } }, testConfig, fixturesPath);
|
|
43
|
+
expect(result.found).toBe(true);
|
|
44
|
+
expect(result.content).toContain('packages/runtime/');
|
|
45
|
+
});
|
|
46
|
+
it('reads a standards artifact by type scope', async () => {
|
|
47
|
+
const result = await readArtifact({ artifact: 'standards', scope: { type: 'solution' } }, testConfig, fixturesPath);
|
|
48
|
+
expect(result.found).toBe(true);
|
|
49
|
+
expect(result.content).toContain('Solution Architecture');
|
|
50
|
+
});
|
|
51
|
+
it('reads code files from the target repo', async () => {
|
|
52
|
+
const targetPath = join(fixturesPath, '..', 'fixtures-target');
|
|
53
|
+
const files = await readCodeFiles(targetPath, ['src/config/loader.ts']);
|
|
54
|
+
expect(files).toHaveLength(1);
|
|
55
|
+
expect(files[0].relativePath).toBe('src/config/loader.ts');
|
|
56
|
+
expect(files[0].content).toContain('loadConfig');
|
|
57
|
+
});
|
|
58
|
+
it('reads a directory of code files recursively', async () => {
|
|
59
|
+
const targetPath = join(fixturesPath, '..', 'fixtures-target');
|
|
60
|
+
const files = await readCodeFiles(targetPath, ['src/config']);
|
|
61
|
+
expect(files.length).toBeGreaterThanOrEqual(2);
|
|
62
|
+
const paths = files.map((f) => f.relativePath);
|
|
63
|
+
expect(paths).toContain('src/config/loader.ts');
|
|
64
|
+
expect(paths).toContain('src/config/resolver.ts');
|
|
65
|
+
});
|
|
66
|
+
it('returns empty for nonexistent code paths', async () => {
|
|
67
|
+
const targetPath = join(fixturesPath, '..', 'fixtures-target');
|
|
68
|
+
const files = await readCodeFiles(targetPath, ['nonexistent/path.ts']);
|
|
69
|
+
expect(files).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('T-01-002b: section extraction', () => {
|
|
73
|
+
const markdown = `# Title
|
|
74
|
+
|
|
75
|
+
## Overview
|
|
76
|
+
|
|
77
|
+
This is the overview section.
|
|
78
|
+
|
|
79
|
+
## Functional Requirements
|
|
80
|
+
|
|
81
|
+
- FR-001
|
|
82
|
+
- FR-002
|
|
83
|
+
|
|
84
|
+
## Non-Functional Requirements
|
|
85
|
+
|
|
86
|
+
- Reliability
|
|
87
|
+
|
|
88
|
+
## Scope Definition
|
|
89
|
+
|
|
90
|
+
In scope items.
|
|
91
|
+
`;
|
|
92
|
+
it('extracts a single named section', () => {
|
|
93
|
+
const result = extractSections(markdown, ['Overview']);
|
|
94
|
+
expect(result).toContain('This is the overview section.');
|
|
95
|
+
expect(result).not.toContain('FR-001');
|
|
96
|
+
});
|
|
97
|
+
it('extracts multiple named sections', () => {
|
|
98
|
+
const result = extractSections(markdown, [
|
|
99
|
+
'Functional Requirements',
|
|
100
|
+
'Non-Functional Requirements',
|
|
101
|
+
]);
|
|
102
|
+
expect(result).toContain('FR-001');
|
|
103
|
+
expect(result).toContain('Reliability');
|
|
104
|
+
expect(result).not.toContain('overview');
|
|
105
|
+
});
|
|
106
|
+
it('is case-insensitive on section names', () => {
|
|
107
|
+
const result = extractSections(markdown, ['overview']);
|
|
108
|
+
expect(result).toContain('This is the overview section.');
|
|
109
|
+
});
|
|
110
|
+
it('returns empty string when no sections match', () => {
|
|
111
|
+
const result = extractSections(markdown, ['Nonexistent Section']);
|
|
112
|
+
expect(result).toBe('');
|
|
113
|
+
});
|
|
114
|
+
it('returns full content when no sections are specified', () => {
|
|
115
|
+
expect(extractSections(markdown, [])).toBe(markdown);
|
|
116
|
+
expect(extractSections(markdown, undefined)).toBe(markdown);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('T-01-002c: token budget enforcement', () => {
|
|
120
|
+
it('counts tokens for a string', () => {
|
|
121
|
+
const tokens = countTokens('Hello, world!');
|
|
122
|
+
expect(tokens).toBeGreaterThan(0);
|
|
123
|
+
expect(tokens).toBeLessThan(20);
|
|
124
|
+
});
|
|
125
|
+
it('respects a tight budget by excluding conditional refs', async () => {
|
|
126
|
+
const perception = {
|
|
127
|
+
always_read: [{ artifact: 'requirements', scope: { epic: 'CREW-01' } }],
|
|
128
|
+
per_task: {
|
|
129
|
+
'implement-story': [
|
|
130
|
+
{ artifact: 'design', scope: { epic: 'CREW-01' } },
|
|
131
|
+
{
|
|
132
|
+
artifact: 'standards',
|
|
133
|
+
scope: { type: 'solution' },
|
|
134
|
+
when: 'always',
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const result = await assembleContext(perception, 'implement-story', { EPIC_ID: 'CREW-01' }, testConfig, fixturesPath, 50);
|
|
140
|
+
expect(result.budgetLimit).toBe(50);
|
|
141
|
+
const conditionalBlocks = result.blocks.filter((b) => b.priority === 'conditional');
|
|
142
|
+
expect(conditionalBlocks).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
it('includes conditional refs when budget is large enough', async () => {
|
|
145
|
+
const perception = {
|
|
146
|
+
per_task: {
|
|
147
|
+
'implement-story': [
|
|
148
|
+
{ artifact: 'requirements', scope: { epic: 'CREW-01' } },
|
|
149
|
+
{
|
|
150
|
+
artifact: 'standards',
|
|
151
|
+
scope: { type: 'solution' },
|
|
152
|
+
when: 'always',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const result = await assembleContext(perception, 'implement-story', { EPIC_ID: 'CREW-01' }, testConfig, fixturesPath, 100_000);
|
|
158
|
+
const conditionalBlocks = result.blocks.filter((b) => b.priority === 'conditional');
|
|
159
|
+
expect(conditionalBlocks.length).toBeGreaterThan(0);
|
|
160
|
+
});
|
|
161
|
+
it('totalTokens reflects the sum of all block tokens', async () => {
|
|
162
|
+
const perception = {
|
|
163
|
+
per_task: {
|
|
164
|
+
task: [{ artifact: 'requirements', scope: { epic: 'CREW-01' } }],
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const result = await assembleContext(perception, 'task', { EPIC_ID: 'CREW-01' }, testConfig, fixturesPath);
|
|
168
|
+
const sum = result.blocks.reduce((acc, b) => acc + b.tokens, 0);
|
|
169
|
+
expect(result.totalTokens).toBe(sum);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('artifact reader: architecture artifact types', () => {
|
|
173
|
+
it('resolves adr-register artifact to architecture/decisions/register.md', async () => {
|
|
174
|
+
const result = await readArtifact({ artifact: 'adr-register', scope: {} }, testConfig, fixturesPath);
|
|
175
|
+
expect(result.path).toContain('architecture/decisions/register.md');
|
|
176
|
+
});
|
|
177
|
+
it('resolves architecture artifact to architecture/{type}.md via scope type', async () => {
|
|
178
|
+
const result = await readArtifact({ artifact: 'architecture', scope: { type: 'solution' } }, testConfig, fixturesPath);
|
|
179
|
+
expect(result.path).toContain('architecture/solution.md');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('T-01-002d: missing artifact handling', () => {
|
|
183
|
+
it('returns found=false for a nonexistent artifact', async () => {
|
|
184
|
+
const result = await readArtifact({ artifact: 'requirements', scope: { epic: 'NONEXISTENT' } }, testConfig, fixturesPath);
|
|
185
|
+
expect(result.found).toBe(false);
|
|
186
|
+
expect(result.content).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
it('flags missing artifacts as gaps in assembled context', async () => {
|
|
189
|
+
const perception = {
|
|
190
|
+
per_task: {
|
|
191
|
+
task: [
|
|
192
|
+
{ artifact: 'requirements', scope: { epic: 'CREW-01' } },
|
|
193
|
+
{ artifact: 'requirements', scope: { epic: 'MISSING-EPIC' } },
|
|
194
|
+
{ artifact: 'design', scope: { epic: 'ALSO-MISSING' } },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const result = await assembleContext(perception, 'task', {}, testConfig, fixturesPath);
|
|
199
|
+
expect(result.gaps).toHaveLength(2);
|
|
200
|
+
expect(result.gaps.some((g) => g.includes('MISSING-EPIC'))).toBe(true);
|
|
201
|
+
expect(result.gaps.some((g) => g.includes('ALSO-MISSING'))).toBe(true);
|
|
202
|
+
expect(result.blocks).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
it('does not throw on missing artifacts', async () => {
|
|
205
|
+
const perception = {
|
|
206
|
+
per_task: {
|
|
207
|
+
task: [{ artifact: 'requirements', scope: { epic: 'NOPE' } }],
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const result = await assembleContext(perception, 'task', {}, testConfig, fixturesPath);
|
|
211
|
+
expect(result.blocks).toHaveLength(0);
|
|
212
|
+
expect(result.gaps).toHaveLength(1);
|
|
213
|
+
expect(result.totalTokens).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('assembleContext integration', () => {
|
|
217
|
+
it('assembles context with section extraction', async () => {
|
|
218
|
+
const perception = {
|
|
219
|
+
always_read: [
|
|
220
|
+
{
|
|
221
|
+
artifact: 'standards',
|
|
222
|
+
scope: { type: 'solution' },
|
|
223
|
+
sections: ['Execution Runtime'],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
per_task: {
|
|
227
|
+
'implement-story': [
|
|
228
|
+
{
|
|
229
|
+
artifact: 'requirements',
|
|
230
|
+
scope: { epic: 'CREW-01' },
|
|
231
|
+
sections: ['Functional Requirements'],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const result = await assembleContext(perception, 'implement-story', { EPIC_ID: 'CREW-01' }, testConfig, fixturesPath);
|
|
237
|
+
expect(result.blocks).toHaveLength(2);
|
|
238
|
+
expect(result.blocks[0].content).toContain('persona tasks');
|
|
239
|
+
expect(result.blocks[0].content).not.toContain('Purpose');
|
|
240
|
+
expect(result.blocks[1].content).toContain('FR-001');
|
|
241
|
+
expect(result.blocks[1].content).not.toContain('Reliability');
|
|
242
|
+
expect(result.gaps).toHaveLength(0);
|
|
243
|
+
expect(result.totalTokens).toBeGreaterThan(0);
|
|
244
|
+
});
|
|
245
|
+
it('interpolates template variables in scope', async () => {
|
|
246
|
+
const perception = {
|
|
247
|
+
per_task: {
|
|
248
|
+
'implement-story': [
|
|
249
|
+
{
|
|
250
|
+
artifact: 'requirements',
|
|
251
|
+
scope: { epic: '{{ .EPIC_ID }}' },
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
const result = await assembleContext(perception, 'implement-story', { EPIC_ID: 'CREW-01' }, testConfig, fixturesPath);
|
|
257
|
+
expect(result.blocks).toHaveLength(1);
|
|
258
|
+
expect(result.blocks[0].content).toContain('CREW-01 delivers the execution runtime');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { assembleContext } from './assembler.js';
|
|
2
|
+
export type { ContextBlock, AssembledContext } from './assembler.js';
|
|
3
|
+
export { readArtifact } from './artifact-reader.js';
|
|
4
|
+
export type { ArtifactReadResult } from './artifact-reader.js';
|
|
5
|
+
export { readCodeFiles } from './code-reader.js';
|
|
6
|
+
export type { CodeFileResult } from './code-reader.js';
|
|
7
|
+
export { extractSections } from './section-extractor.js';
|
|
8
|
+
export { countTokens, defaultTokenBudget } from './token-budget.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { assembleContext } from './assembler.js';
|
|
2
|
+
export { readArtifact } from './artifact-reader.js';
|
|
3
|
+
export { readCodeFiles } from './code-reader.js';
|
|
4
|
+
export { extractSections } from './section-extractor.js';
|
|
5
|
+
export { countTokens, defaultTokenBudget } from './token-budget.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract named sections from a Markdown document.
|
|
3
|
+
*
|
|
4
|
+
* Splits by level-2 headings (`## `), then returns only those sections
|
|
5
|
+
* whose heading text matches one of the requested section names (case-insensitive).
|
|
6
|
+
* If no sections are requested (empty array or undefined), returns the full content.
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractSections(markdown: string, sectionNames?: string[]): string;
|
|
9
|
+
//# sourceMappingURL=section-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"section-extractor.d.ts","sourceRoot":"","sources":["../../src/context/section-extractor.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAwBjF"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract named sections from a Markdown document.
|
|
3
|
+
*
|
|
4
|
+
* Splits by level-2 headings (`## `), then returns only those sections
|
|
5
|
+
* whose heading text matches one of the requested section names (case-insensitive).
|
|
6
|
+
* If no sections are requested (empty array or undefined), returns the full content.
|
|
7
|
+
*/
|
|
8
|
+
export function extractSections(markdown, sectionNames) {
|
|
9
|
+
if (!sectionNames || sectionNames.length === 0)
|
|
10
|
+
return markdown;
|
|
11
|
+
const lowerNames = new Set(sectionNames.map((s) => s.toLowerCase().trim()));
|
|
12
|
+
const lines = markdown.split('\n');
|
|
13
|
+
const sections = [];
|
|
14
|
+
let current = null;
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
const headingMatch = /^##\s+(.+)$/.exec(line);
|
|
17
|
+
if (headingMatch) {
|
|
18
|
+
if (current)
|
|
19
|
+
sections.push(current);
|
|
20
|
+
current = { heading: headingMatch[1].trim(), lines: [line] };
|
|
21
|
+
}
|
|
22
|
+
else if (current) {
|
|
23
|
+
current.lines.push(line);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (current)
|
|
27
|
+
sections.push(current);
|
|
28
|
+
const matched = sections.filter((s) => lowerNames.has(s.heading.toLowerCase()));
|
|
29
|
+
if (matched.length === 0)
|
|
30
|
+
return '';
|
|
31
|
+
return matched.map((s) => s.lines.join('\n')).join('\n\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Count tokens in a string using the cl100k_base encoding.
|
|
3
|
+
* This is a conservative approximation for Claude (slightly over-counts).
|
|
4
|
+
*/
|
|
5
|
+
export declare function countTokens(text: string): number;
|
|
6
|
+
/**
|
|
7
|
+
* Default token budget: 100,000 tokens.
|
|
8
|
+
* Configurable via CREW_TOKEN_BUDGET environment variable.
|
|
9
|
+
*/
|
|
10
|
+
export declare function defaultTokenBudget(): number;
|
|
11
|
+
//# sourceMappingURL=token-budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-budget.d.ts","sourceRoot":"","sources":["../../src/context/token-budget.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAO3C"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { encodingForModel } from 'js-tiktoken';
|
|
2
|
+
const encoder = encodingForModel('gpt-4o');
|
|
3
|
+
/**
|
|
4
|
+
* Count tokens in a string using the cl100k_base encoding.
|
|
5
|
+
* This is a conservative approximation for Claude (slightly over-counts).
|
|
6
|
+
*/
|
|
7
|
+
export function countTokens(text) {
|
|
8
|
+
return encoder.encode(text).length;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Default token budget: 100,000 tokens.
|
|
12
|
+
* Configurable via CREW_TOKEN_BUDGET environment variable.
|
|
13
|
+
*/
|
|
14
|
+
export function defaultTokenBudget() {
|
|
15
|
+
const env = process.env['CREW_TOKEN_BUDGET'];
|
|
16
|
+
if (env) {
|
|
17
|
+
const parsed = parseInt(env, 10);
|
|
18
|
+
if (!Number.isNaN(parsed) && parsed > 0)
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
return 100_000;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control.test.d.ts","sourceRoot":"","sources":["../../src/control/control.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { acquireLock } from './lock-manager.js';
|
|
6
|
+
import { transitionState, persistState, loadState } from './run-state.js';
|
|
7
|
+
import { generateRunId, generateIdempotencyKey } from './id-generator.js';
|
|
8
|
+
import { LockAcquisitionError, InvalidStateTransitionError } from '../common/errors.js';
|
|
9
|
+
let runsDir;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
runsDir = await mkdtemp(join(tmpdir(), 'crew-control-test-'));
|
|
12
|
+
});
|
|
13
|
+
describe('T-01-007a: lock acquisition and release', () => {
|
|
14
|
+
it('acquires a lock and creates a lock file', async () => {
|
|
15
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
16
|
+
expect(handle.runId).toBeTruthy();
|
|
17
|
+
expect(handle.lockPath).toContain('.locks');
|
|
18
|
+
expect(handle.lockPath).toContain('engineer-implement-CREW-01-001.lock');
|
|
19
|
+
const lockContent = await readFile(handle.lockPath, 'utf-8');
|
|
20
|
+
const parsed = JSON.parse(lockContent);
|
|
21
|
+
expect(parsed.runId).toBe(handle.runId);
|
|
22
|
+
expect(parsed.ttlMs).toBeGreaterThan(0);
|
|
23
|
+
await handle.release();
|
|
24
|
+
});
|
|
25
|
+
it('releases the lock by deleting the lock file', async () => {
|
|
26
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
27
|
+
await handle.release();
|
|
28
|
+
const secondHandle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
29
|
+
expect(secondHandle.runId).toBeTruthy();
|
|
30
|
+
await secondHandle.release();
|
|
31
|
+
});
|
|
32
|
+
it('release is idempotent', async () => {
|
|
33
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
34
|
+
await handle.release();
|
|
35
|
+
await expect(handle.release()).resolves.toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('T-01-007b: duplicate run prevention', () => {
|
|
39
|
+
it('throws LockAcquisitionError when lock is held', async () => {
|
|
40
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
41
|
+
await expect(acquireLock('engineer', 'implement', 'CREW-01-001', runsDir)).rejects.toThrow(LockAcquisitionError);
|
|
42
|
+
await handle.release();
|
|
43
|
+
});
|
|
44
|
+
it('allows different scopes to lock concurrently', async () => {
|
|
45
|
+
const h1 = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir);
|
|
46
|
+
const h2 = await acquireLock('engineer', 'implement', 'CREW-01-002', runsDir);
|
|
47
|
+
const h3 = await acquireLock('reviewer', 'review', 'CREW-01-001', runsDir);
|
|
48
|
+
expect(h1.runId).not.toBe(h2.runId);
|
|
49
|
+
expect(h1.runId).not.toBe(h3.runId);
|
|
50
|
+
await h1.release();
|
|
51
|
+
await h2.release();
|
|
52
|
+
await h3.release();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('T-01-007c: state transitions', () => {
|
|
56
|
+
const baseRecord = {
|
|
57
|
+
run_id: 'run-test-001',
|
|
58
|
+
persona: 'engineer',
|
|
59
|
+
task: 'implement-story',
|
|
60
|
+
scope: 'CREW-01-001',
|
|
61
|
+
status: 'queued',
|
|
62
|
+
started_at: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
it('transitions through the happy path', () => {
|
|
65
|
+
let r = transitionState(baseRecord, 'running');
|
|
66
|
+
expect(r.status).toBe('running');
|
|
67
|
+
r = transitionState(r, 'evaluating');
|
|
68
|
+
expect(r.status).toBe('evaluating');
|
|
69
|
+
r = transitionState(r, 'publishing');
|
|
70
|
+
expect(r.status).toBe('publishing');
|
|
71
|
+
r = transitionState(r, 'published');
|
|
72
|
+
expect(r.status).toBe('published');
|
|
73
|
+
expect(r.completed_at).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
it('transitions to failed from running', () => {
|
|
76
|
+
const r = transitionState({ ...baseRecord, status: 'running' }, 'failed');
|
|
77
|
+
expect(r.status).toBe('failed');
|
|
78
|
+
expect(r.completed_at).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
it('transitions to awaiting_review from publishing', () => {
|
|
81
|
+
const r = transitionState({ ...baseRecord, status: 'publishing' }, 'awaiting_review');
|
|
82
|
+
expect(r.status).toBe('awaiting_review');
|
|
83
|
+
});
|
|
84
|
+
it('rejects invalid transitions', () => {
|
|
85
|
+
expect(() => transitionState(baseRecord, 'evaluating')).toThrow(InvalidStateTransitionError);
|
|
86
|
+
expect(() => transitionState(baseRecord, 'published')).toThrow(InvalidStateTransitionError);
|
|
87
|
+
expect(() => transitionState({ ...baseRecord, status: 'published' }, 'running')).toThrow(InvalidStateTransitionError);
|
|
88
|
+
});
|
|
89
|
+
it('persists and loads state from disk', async () => {
|
|
90
|
+
const runDir = join(runsDir, 'run-test-001');
|
|
91
|
+
await persistState(runDir, baseRecord);
|
|
92
|
+
const loaded = await loadState(runDir);
|
|
93
|
+
expect(loaded).not.toBeNull();
|
|
94
|
+
expect(loaded.run_id).toBe('run-test-001');
|
|
95
|
+
expect(loaded.status).toBe('queued');
|
|
96
|
+
});
|
|
97
|
+
it('returns null for nonexistent state file', async () => {
|
|
98
|
+
const loaded = await loadState(join(runsDir, 'nonexistent'));
|
|
99
|
+
expect(loaded).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('T-01-007d: stale lock cleanup', () => {
|
|
103
|
+
it('auto-releases a stale lock and allows new acquisition', async () => {
|
|
104
|
+
const locksDir = join(runsDir, '.locks');
|
|
105
|
+
await mkdir(locksDir, { recursive: true });
|
|
106
|
+
const lockPath = join(locksDir, 'engineer-implement-CREW-01-001.lock');
|
|
107
|
+
const staleLock = {
|
|
108
|
+
runId: 'run-stale-old',
|
|
109
|
+
timestamp: new Date(Date.now() - 7_200_000).toISOString(), // 2 hours ago
|
|
110
|
+
ttlMs: 3_600_000, // 1 hour TTL -- expired
|
|
111
|
+
};
|
|
112
|
+
await writeFile(lockPath, JSON.stringify(staleLock), 'utf-8');
|
|
113
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir, 3_600_000);
|
|
114
|
+
expect(handle.runId).not.toBe('run-stale-old');
|
|
115
|
+
await handle.release();
|
|
116
|
+
});
|
|
117
|
+
it('does not release a fresh lock', async () => {
|
|
118
|
+
const handle = await acquireLock('engineer', 'implement', 'CREW-01-001', runsDir, 3_600_000);
|
|
119
|
+
await expect(acquireLock('engineer', 'implement', 'CREW-01-001', runsDir, 3_600_000)).rejects.toThrow(LockAcquisitionError);
|
|
120
|
+
await handle.release();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('id generation', () => {
|
|
124
|
+
it('generates a run ID containing persona, task, and scope', () => {
|
|
125
|
+
const id = generateRunId('engineer', 'implement-story', 'CREW-01-001');
|
|
126
|
+
expect(id).toContain('run-');
|
|
127
|
+
expect(id).toContain('engineer');
|
|
128
|
+
expect(id).toContain('implement-story');
|
|
129
|
+
expect(id).toContain('CREW-01-001');
|
|
130
|
+
});
|
|
131
|
+
it('generates unique idempotency keys', () => {
|
|
132
|
+
const k1 = generateIdempotencyKey();
|
|
133
|
+
const k2 = generateIdempotencyKey();
|
|
134
|
+
expect(k1).not.toBe(k2);
|
|
135
|
+
expect(k1).toHaveLength(32);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a run ID in the format:
|
|
3
|
+
* run-{ISO timestamp}-{persona}-{task}-{scope}
|
|
4
|
+
*
|
|
5
|
+
* The timestamp uses a filesystem-safe format (colons replaced with dashes).
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateRunId(persona: string, task: string, scope: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Generate an idempotency key: a random 16-byte hex string.
|
|
10
|
+
*/
|
|
11
|
+
export declare function generateIdempotencyKey(): string;
|
|
12
|
+
//# sourceMappingURL=id-generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"id-generator.d.ts","sourceRoot":"","sources":["../../src/control/id-generator.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMlF;AAED;;GAEG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a run ID in the format:
|
|
4
|
+
* run-{ISO timestamp}-{persona}-{task}-{scope}
|
|
5
|
+
*
|
|
6
|
+
* The timestamp uses a filesystem-safe format (colons replaced with dashes).
|
|
7
|
+
*/
|
|
8
|
+
export function generateRunId(persona, task, scope) {
|
|
9
|
+
const ts = new Date()
|
|
10
|
+
.toISOString()
|
|
11
|
+
.replace(/:/g, '-')
|
|
12
|
+
.replace(/\.\d+Z$/, 'Z');
|
|
13
|
+
return `run-${ts}-${persona}-${task}-${scope}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate an idempotency key: a random 16-byte hex string.
|
|
17
|
+
*/
|
|
18
|
+
export function generateIdempotencyKey() {
|
|
19
|
+
return randomBytes(16).toString('hex');
|
|
20
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { acquireLock } from './lock-manager.js';
|
|
2
|
+
export type { LockHandle } from './lock-manager.js';
|
|
3
|
+
export { transitionState, persistState, loadState } from './run-state.js';
|
|
4
|
+
export { generateRunId, generateIdempotencyKey } from './id-generator.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/control/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC1E,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface LockHandle {
|
|
2
|
+
lockPath: string;
|
|
3
|
+
runId: string;
|
|
4
|
+
release(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Acquire a file-based lock scoped to persona + task + scope.
|
|
8
|
+
*
|
|
9
|
+
* If a lock file exists and is within TTL, throws LockAcquisitionError.
|
|
10
|
+
* If a lock file exists but is past TTL, it is automatically released (stale lock cleanup).
|
|
11
|
+
*/
|
|
12
|
+
export declare function acquireLock(persona: string, task: string, scope: string, runsDir: string, ttlMs?: number): Promise<LockHandle>;
|
|
13
|
+
//# sourceMappingURL=lock-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-manager.d.ts","sourceRoot":"","sources":["../../src/control/lock-manager.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAMD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,UAAU,CAAC,CAwCrB"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { writeFile, readFile, unlink, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LockAcquisitionError } from '../common/errors.js';
|
|
4
|
+
const DEFAULT_TTL_MS = 3_600_000; // 1 hour
|
|
5
|
+
function getLockTtl() {
|
|
6
|
+
const env = process.env['CREW_LOCK_TTL_MS'];
|
|
7
|
+
if (env) {
|
|
8
|
+
const parsed = parseInt(env, 10);
|
|
9
|
+
if (!Number.isNaN(parsed) && parsed >= 300_000)
|
|
10
|
+
return parsed;
|
|
11
|
+
}
|
|
12
|
+
return DEFAULT_TTL_MS;
|
|
13
|
+
}
|
|
14
|
+
function lockFileName(persona, task, scope) {
|
|
15
|
+
return `${persona}-${task}-${scope}.lock`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Acquire a file-based lock scoped to persona + task + scope.
|
|
19
|
+
*
|
|
20
|
+
* If a lock file exists and is within TTL, throws LockAcquisitionError.
|
|
21
|
+
* If a lock file exists but is past TTL, it is automatically released (stale lock cleanup).
|
|
22
|
+
*/
|
|
23
|
+
export async function acquireLock(persona, task, scope, runsDir, ttlMs) {
|
|
24
|
+
const ttl = ttlMs ?? getLockTtl();
|
|
25
|
+
const locksDir = join(runsDir, '.locks');
|
|
26
|
+
await mkdir(locksDir, { recursive: true });
|
|
27
|
+
const lockFile = lockFileName(persona, task, scope);
|
|
28
|
+
const lockPath = join(locksDir, lockFile);
|
|
29
|
+
const existing = await readLock(lockPath);
|
|
30
|
+
if (existing) {
|
|
31
|
+
const age = Date.now() - new Date(existing.timestamp).getTime();
|
|
32
|
+
if (age < existing.ttlMs) {
|
|
33
|
+
throw new LockAcquisitionError(`Lock held by run '${existing.runId}' (acquired ${existing.timestamp}, TTL ${existing.ttlMs}ms). Scope: ${persona}-${task}-${scope}`);
|
|
34
|
+
}
|
|
35
|
+
// Stale lock -- release it
|
|
36
|
+
await releaseLockFile(lockPath);
|
|
37
|
+
}
|
|
38
|
+
const runId = `run-${new Date()
|
|
39
|
+
.toISOString()
|
|
40
|
+
.replace(/:/g, '-')
|
|
41
|
+
.replace(/\.\d+Z$/, 'Z')}-${persona}-${task}-${scope}`;
|
|
42
|
+
const lockData = {
|
|
43
|
+
runId,
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
ttlMs: ttl,
|
|
46
|
+
};
|
|
47
|
+
await writeFile(lockPath, JSON.stringify(lockData, null, 2), 'utf-8');
|
|
48
|
+
return {
|
|
49
|
+
lockPath,
|
|
50
|
+
runId,
|
|
51
|
+
async release() {
|
|
52
|
+
await releaseLockFile(lockPath);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function readLock(lockPath) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(lockPath, 'utf-8');
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function releaseLockFile(lockPath) {
|
|
66
|
+
try {
|
|
67
|
+
await unlink(lockPath);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* file may already be gone */
|
|
71
|
+
}
|
|
72
|
+
}
|