feat: Add minimal GitHub repo configuration and issue assistant setup

fix: Export workspace manager in code work spawner
feat: Enhance CLI responder to handle JSONL events
feat: Implement job status enumeration in database
feat: Add logging capabilities to IssueAssistantApp
This commit is contained in:
insleker 2026-04-04 22:12:03 +08:00
parent aa8d122ab9
commit 42b89719fd
16 changed files with 830 additions and 86 deletions

View File

@ -14,6 +14,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: dart pub get run: dart pub get
- name: Build generated files
run: dart run build_runner build --delete-conflicting-outputs
- name: Static analysis - name: Static analysis
run: dart analyze run: dart analyze

3
.gitignore vendored
View File

@ -8,6 +8,7 @@ agent-orchestrator.yaml
# !/.agents # !/.agents
!/.git !/.git
!/.github !/.github
!/.vscode
/skills/* /skills/*
!/skills/dart-gherkin-tests !/skills/dart-gherkin-tests
*.g.dart

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"qwtel.sqlite-viewer"
]
}

View File

@ -49,7 +49,7 @@ defaults:
responders: responders:
- id: codex - id: codex
command: codex command: codex
args: ["exec", "--json-output"] args: ["exec", "--json"]
timeout: 10m timeout: 10m
- id: opencode - id: opencode
command: opencode command: opencode
@ -66,8 +66,17 @@ projects:
Project entries can override `issueAssistant` fields when a repo needs a Project entries can override `issueAssistant` fields when a repo needs a
different prompt, mention triggers, or responder chain. different prompt, mention triggers, or responder chain.
For a more minimal Agent Orchestrator compatible subset, start from
[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts
AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
`worktreeDir` for bug-verification worktrees.
## Run ## Run
```bash
dart run build_runner build
```
Single poll cycle: Single poll cycle:
```bash ```bash

View File

@ -2,8 +2,11 @@ import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:logging/logging.dart';
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
_configureLogging();
final parser = ArgParser() final parser = ArgParser()
..addFlag( ..addFlag(
'once', 'once',
@ -36,6 +39,22 @@ Future<void> main(List<String> arguments) async {
} }
final config = await AppConfig.load(results['config'] as String); final config = await AppConfig.load(results['config'] as String);
final logger = Logger('code_work_spawner.cli');
logger.info(
'loaded config=${config.configPath} '
'projects=${config.projects.length} '
'worktreeDir=${config.worktreeDir ?? "(system temp)"}',
);
for (final project in config.projects.values) {
final repoDirectory = Directory(project.path);
logger.info(
'project=${project.key} '
'repo=${project.repo} '
'path=${project.path} '
'exists=${await repoDirectory.exists()} '
'enabled=${project.issueAssistant.enabled}',
);
}
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: config, config: config,
databasePath: results['db'] as String, databasePath: results['db'] as String,
@ -53,3 +72,13 @@ Future<void> main(List<String> arguments) async {
await app.close(); await app.close();
} }
} }
void _configureLogging() {
hierarchicalLoggingEnabled = true;
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((record) {
stdout.writeln(
'[${record.loggerName}] ${record.level.name}: ${record.message}',
);
});
}

View File

@ -0,0 +1,24 @@
# Minimal setup for a single GitHub repo with GitHub Issues
# AO-compatible subset supported by code_work_spawner.
# Replies require at least one configured responder.
dataDir: ~/.agent-orchestrator
worktreeDir: ~/.worktrees
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@codex"]
responders:
# Replace this with a responder available on your machine.
- id: codex
command: codex
args: ["exec", "--json"]
timeout: 10m
projects:
code-work-spawner:
repo: existedinnettw/code_work_spawner
path: ~/code_work_spawner
defaultBranch: main

View File

@ -4,5 +4,5 @@ pre-commit:
run: dart format --output=none --set-exit-if-changed . run: dart format --output=none --set-exit-if-changed .
analyze: analyze:
run: dart analyze run: dart analyze
test: # test:
run: dart test # run: dart test

View File

@ -2,3 +2,4 @@ export 'src/config.dart';
export 'src/database.dart'; export 'src/database.dart';
export 'src/github_client.dart'; export 'src/github_client.dart';
export 'src/issue_assistant_app.dart'; export 'src/issue_assistant_app.dart';
export 'src/workspace_manager.dart';

View File

@ -87,6 +87,11 @@ class CliResponderRunner {
try { try {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>(); return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
} on FormatException { } on FormatException {
final eventJson = _decodeJsonlEvents(trimmed);
if (eventJson != null) {
return eventJson;
}
final first = trimmed.indexOf('{'); final first = trimmed.indexOf('{');
final last = trimmed.lastIndexOf('}'); final last = trimmed.lastIndexOf('}');
if (first == -1 || last <= first) { if (first == -1 || last <= first) {
@ -96,6 +101,45 @@ class CliResponderRunner {
return (jsonDecode(snippet) as Map).cast<String, dynamic>(); return (jsonDecode(snippet) as Map).cast<String, dynamic>();
} }
} }
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
Map<String, dynamic>? lastEvent;
for (final line in const LineSplitter().convert(stdout)) {
final trimmed = line.trim();
if (trimmed.isEmpty || !trimmed.startsWith('{')) {
continue;
}
try {
final decoded = jsonDecode(trimmed);
if (decoded is! Map) {
continue;
}
lastEvent = decoded.cast<String, dynamic>();
final item = lastEvent['item'];
if (item is Map &&
item['type']?.toString() == 'agent_message' &&
item['text'] is String) {
final text = (item['text'] as String).trim();
if (text.startsWith('{')) {
return (jsonDecode(text) as Map).cast<String, dynamic>();
}
}
} on FormatException {
continue;
}
}
if (lastEvent case {'text': final String text}) {
final trimmed = text.trim();
if (trimmed.startsWith('{')) {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
}
}
return null;
}
} }
class ResponderResult { class ResponderResult {

View File

@ -7,11 +7,15 @@ import 'package:yaml/yaml.dart';
class AppConfig { class AppConfig {
AppConfig({ AppConfig({
required this.configPath, required this.configPath,
required this.dataDir,
required this.worktreeDir,
required this.defaults, required this.defaults,
required this.projects, required this.projects,
}); });
final String configPath; final String configPath;
final String? dataDir;
final String? worktreeDir;
final IssueAssistantConfig defaults; final IssueAssistantConfig defaults;
final Map<String, ProjectConfig> projects; final Map<String, ProjectConfig> projects;
@ -23,11 +27,80 @@ class AppConfig {
throw const FormatException('Config root must be a YAML map.'); throw const FormatException('Config root must be a YAML map.');
} }
final defaultsMap = _asMap(yaml['defaults']); final root = _asMap(yaml);
final dialect = _detectDialect(root);
return switch (dialect) {
_ConfigDialect.current => _loadCurrentConfig(path, root),
_ConfigDialect.aoSubset => _loadAoSubsetConfig(path, root),
};
}
static AppConfig _loadCurrentConfig(String path, Map<String, dynamic> root) {
_validateKeys(
scope: 'config',
map: root,
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
);
final defaultsMap = _asMap(root['defaults']);
final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']); final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']);
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap); final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
final projects = _loadProjects(
root['projects'],
defaultAssistant: defaults,
defaultAssistantMap: defaultAssistantMap,
);
final projectsNode = yaml['projects']; return AppConfig(
configPath: p.normalize(p.absolute(path)),
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
defaults: defaults,
projects: projects,
);
}
static AppConfig _loadAoSubsetConfig(String path, Map<String, dynamic> root) {
_validateKeys(
scope: 'config',
map: root,
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
);
final defaultsMap = _asMap(root['defaults']);
_validateKeys(
scope: 'defaults',
map: defaultsMap,
allowed: {
'defaultBranch',
'default_branch',
'sessionPrefix',
'session_prefix',
},
);
final defaultAssistantMap = <String, dynamic>{};
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
final projects = _loadAoSubsetProjects(
root['projects'],
defaultsMap: defaultsMap,
defaultAssistant: defaults,
defaultAssistantMap: defaultAssistantMap,
);
return AppConfig(
configPath: p.normalize(p.absolute(path)),
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
defaults: defaults,
projects: projects,
);
}
static Map<String, ProjectConfig> _loadProjects(
Object? projectsNode, {
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) { if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.'); throw const FormatException('projects must be a non-empty map.');
} }
@ -39,16 +112,56 @@ class AppConfig {
projects[key] = ProjectConfig.fromMap( projects[key] = ProjectConfig.fromMap(
key: key, key: key,
map: value, map: value,
defaultAssistant: defaults, defaultAssistant: defaultAssistant,
defaultAssistantMap: defaultAssistantMap, defaultAssistantMap: defaultAssistantMap,
); );
} }
return projects;
}
return AppConfig( static Map<String, ProjectConfig> _loadAoSubsetProjects(
configPath: p.normalize(p.absolute(path)), Object? projectsNode, {
defaults: defaults, required Map<String, dynamic> defaultsMap,
projects: projects, required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.');
}
final projects = <String, ProjectConfig>{};
for (final entry in projectsNode.entries) {
final key = entry.key.toString();
final value = _asMap(entry.value);
_validateKeys(
scope: 'projects.$key',
map: value,
allowed: {
'repo',
'path',
'defaultBranch',
'default_branch',
'sessionPrefix',
'session_prefix',
},
); );
final merged = <String, dynamic>{...defaultsMap, ...value};
projects[key] = ProjectConfig.fromMap(
key: key,
map: merged,
defaultAssistant: defaultAssistant,
defaultAssistantMap: defaultAssistantMap,
);
}
return projects;
}
static _ConfigDialect _detectDialect(Map<String, dynamic> root) {
final defaultsMap = _asMap(root['defaults']);
if (defaultsMap.containsKey('issueAssistant')) {
return _ConfigDialect.current;
}
return _ConfigDialect.aoSubset;
} }
static Map<String, dynamic> _asMap(Object? value) { static Map<String, dynamic> _asMap(Object? value) {
@ -68,6 +181,18 @@ class AppConfig {
throw FormatException('Expected map, got ${value.runtimeType}.'); throw FormatException('Expected map, got ${value.runtimeType}.');
} }
static void _validateKeys({
required String scope,
required Map<String, dynamic> map,
required Set<String> allowed,
}) {
for (final key in map.keys) {
if (!allowed.contains(key)) {
throw FormatException('Unsupported config field: $scope.$key');
}
}
}
} }
class ProjectConfig { class ProjectConfig {
@ -237,11 +362,10 @@ class CliResponderConfig {
throw FormatException('Responder "$id" must define command.'); throw FormatException('Responder "$id" must define command.');
} }
final args = final args = _normalizeArgs(
(map['args'] as List?) (map['args'] as List?)?.map((item) => item.toString()) ??
?.map((item) => item.toString()) const <String>[],
.toList(growable: false) ?? );
const <String>[];
final env = AppConfig._asMap( final env = AppConfig._asMap(
map['env'], map['env'],
).map((key, value) => MapEntry(key, value.toString())); ).map((key, value) => MapEntry(key, value.toString()));
@ -257,6 +381,12 @@ class CliResponderConfig {
), ),
); );
} }
static List<String> _normalizeArgs(Iterable<String> args) {
return args
.map((arg) => arg == '--json-output' ? '--json' : arg)
.toList(growable: false);
}
} }
enum AssistantCapability { enum AssistantCapability {
@ -298,6 +428,13 @@ String _expandHome(String path) {
return p.normalize(p.join(home, path.substring(2))); return p.normalize(p.join(home, path.substring(2)));
} }
String? _expandOptionalPath(String? path) {
if (path == null || path.isEmpty) {
return null;
}
return _expandHome(path);
}
String _deriveSessionPrefix(String key) { String _deriveSessionPrefix(String key) {
if (key.contains('-')) { if (key.contains('-')) {
return key return key
@ -319,3 +456,5 @@ String _deriveSessionPrefix(String key) {
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase(); return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
} }
enum _ConfigDialect { current, aoSubset }

View File

@ -6,6 +6,8 @@ import 'package:path/path.dart' as p;
part 'database.g.dart'; part 'database.g.dart';
enum JobStatus { queued, running, completed, failed, skipped }
class ProjectStates extends Table { class ProjectStates extends Table {
TextColumn get projectKey => text()(); TextColumn get projectKey => text()();
TextColumn get repo => text()(); TextColumn get repo => text()();
@ -39,7 +41,7 @@ class Jobs extends Table {
IntColumn get issueNumber => integer()(); IntColumn get issueNumber => integer()();
TextColumn get fingerprint => text()(); TextColumn get fingerprint => text()();
TextColumn get trigger => text()(); TextColumn get trigger => text()();
TextColumn get status => text()(); TextColumn get status => textEnum<JobStatus>()();
TextColumn get createdAt => text()(); TextColumn get createdAt => text()();
TextColumn get updatedAt => text()(); TextColumn get updatedAt => text()();
} }
@ -179,7 +181,7 @@ class AppDatabase extends _$AppDatabase {
required int issueNumber, required int issueNumber,
required String fingerprint, required String fingerprint,
required String trigger, required String trigger,
required String status, required JobStatus status,
}) { }) {
final now = DateTime.now().toUtc().toIso8601String(); final now = DateTime.now().toUtc().toIso8601String();
return into(jobs).insert( return into(jobs).insert(
@ -195,7 +197,7 @@ class AppDatabase extends _$AppDatabase {
); );
} }
Future<void> updateJobStatus(int jobId, String status) { Future<void> updateJobStatus(int jobId, JobStatus status) {
return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write( return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write(
JobsCompanion( JobsCompanion(
status: Value(status), status: Value(status),

View File

@ -1004,15 +1004,15 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
); );
static const VerificationMeta _statusMeta = const VerificationMeta('status');
@override @override
late final GeneratedColumn<String> status = GeneratedColumn<String>( late final GeneratedColumnWithTypeConverter<JobStatus, String> status =
GeneratedColumn<String>(
'status', 'status',
aliasedName, aliasedName,
false, false,
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
); ).withConverter<JobStatus>($JobsTable.$converterstatus);
static const VerificationMeta _createdAtMeta = const VerificationMeta( static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt', 'createdAt',
); );
@ -1099,14 +1099,6 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
} else if (isInserting) { } else if (isInserting) {
context.missing(_triggerMeta); context.missing(_triggerMeta);
} }
if (data.containsKey('status')) {
context.handle(
_statusMeta,
status.isAcceptableOrUnknown(data['status']!, _statusMeta),
);
} else if (isInserting) {
context.missing(_statusMeta);
}
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle( context.handle(
_createdAtMeta, _createdAtMeta,
@ -1152,10 +1144,12 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}trigger'], data['${effectivePrefix}trigger'],
)!, )!,
status: attachedDatabase.typeMapping.read( status: $JobsTable.$converterstatus.fromSql(
attachedDatabase.typeMapping.read(
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}status'], data['${effectivePrefix}status'],
)!, )!,
),
createdAt: attachedDatabase.typeMapping.read( createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}created_at'], data['${effectivePrefix}created_at'],
@ -1171,6 +1165,9 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
$JobsTable createAlias(String alias) { $JobsTable createAlias(String alias) {
return $JobsTable(attachedDatabase, alias); return $JobsTable(attachedDatabase, alias);
} }
static JsonTypeConverter2<JobStatus, String, String> $converterstatus =
const EnumNameConverter<JobStatus>(JobStatus.values);
} }
class Job extends DataClass implements Insertable<Job> { class Job extends DataClass implements Insertable<Job> {
@ -1179,7 +1176,7 @@ class Job extends DataClass implements Insertable<Job> {
final int issueNumber; final int issueNumber;
final String fingerprint; final String fingerprint;
final String trigger; final String trigger;
final String status; final JobStatus status;
final String createdAt; final String createdAt;
final String updatedAt; final String updatedAt;
const Job({ const Job({
@ -1200,7 +1197,11 @@ class Job extends DataClass implements Insertable<Job> {
map['issue_number'] = Variable<int>(issueNumber); map['issue_number'] = Variable<int>(issueNumber);
map['fingerprint'] = Variable<String>(fingerprint); map['fingerprint'] = Variable<String>(fingerprint);
map['trigger'] = Variable<String>(trigger); map['trigger'] = Variable<String>(trigger);
map['status'] = Variable<String>(status); {
map['status'] = Variable<String>(
$JobsTable.$converterstatus.toSql(status),
);
}
map['created_at'] = Variable<String>(createdAt); map['created_at'] = Variable<String>(createdAt);
map['updated_at'] = Variable<String>(updatedAt); map['updated_at'] = Variable<String>(updatedAt);
return map; return map;
@ -1230,7 +1231,9 @@ class Job extends DataClass implements Insertable<Job> {
issueNumber: serializer.fromJson<int>(json['issueNumber']), issueNumber: serializer.fromJson<int>(json['issueNumber']),
fingerprint: serializer.fromJson<String>(json['fingerprint']), fingerprint: serializer.fromJson<String>(json['fingerprint']),
trigger: serializer.fromJson<String>(json['trigger']), trigger: serializer.fromJson<String>(json['trigger']),
status: serializer.fromJson<String>(json['status']), status: $JobsTable.$converterstatus.fromJson(
serializer.fromJson<String>(json['status']),
),
createdAt: serializer.fromJson<String>(json['createdAt']), createdAt: serializer.fromJson<String>(json['createdAt']),
updatedAt: serializer.fromJson<String>(json['updatedAt']), updatedAt: serializer.fromJson<String>(json['updatedAt']),
); );
@ -1244,7 +1247,9 @@ class Job extends DataClass implements Insertable<Job> {
'issueNumber': serializer.toJson<int>(issueNumber), 'issueNumber': serializer.toJson<int>(issueNumber),
'fingerprint': serializer.toJson<String>(fingerprint), 'fingerprint': serializer.toJson<String>(fingerprint),
'trigger': serializer.toJson<String>(trigger), 'trigger': serializer.toJson<String>(trigger),
'status': serializer.toJson<String>(status), 'status': serializer.toJson<String>(
$JobsTable.$converterstatus.toJson(status),
),
'createdAt': serializer.toJson<String>(createdAt), 'createdAt': serializer.toJson<String>(createdAt),
'updatedAt': serializer.toJson<String>(updatedAt), 'updatedAt': serializer.toJson<String>(updatedAt),
}; };
@ -1256,7 +1261,7 @@ class Job extends DataClass implements Insertable<Job> {
int? issueNumber, int? issueNumber,
String? fingerprint, String? fingerprint,
String? trigger, String? trigger,
String? status, JobStatus? status,
String? createdAt, String? createdAt,
String? updatedAt, String? updatedAt,
}) => Job( }) => Job(
@ -1334,7 +1339,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
final Value<int> issueNumber; final Value<int> issueNumber;
final Value<String> fingerprint; final Value<String> fingerprint;
final Value<String> trigger; final Value<String> trigger;
final Value<String> status; final Value<JobStatus> status;
final Value<String> createdAt; final Value<String> createdAt;
final Value<String> updatedAt; final Value<String> updatedAt;
const JobsCompanion({ const JobsCompanion({
@ -1353,7 +1358,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
required int issueNumber, required int issueNumber,
required String fingerprint, required String fingerprint,
required String trigger, required String trigger,
required String status, required JobStatus status,
required String createdAt, required String createdAt,
required String updatedAt, required String updatedAt,
}) : projectKey = Value(projectKey), }) : projectKey = Value(projectKey),
@ -1391,7 +1396,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
Value<int>? issueNumber, Value<int>? issueNumber,
Value<String>? fingerprint, Value<String>? fingerprint,
Value<String>? trigger, Value<String>? trigger,
Value<String>? status, Value<JobStatus>? status,
Value<String>? createdAt, Value<String>? createdAt,
Value<String>? updatedAt, Value<String>? updatedAt,
}) { }) {
@ -1426,7 +1431,9 @@ class JobsCompanion extends UpdateCompanion<Job> {
map['trigger'] = Variable<String>(trigger.value); map['trigger'] = Variable<String>(trigger.value);
} }
if (status.present) { if (status.present) {
map['status'] = Variable<String>(status.value); map['status'] = Variable<String>(
$JobsTable.$converterstatus.toSql(status.value),
);
} }
if (createdAt.present) { if (createdAt.present) {
map['created_at'] = Variable<String>(createdAt.value); map['created_at'] = Variable<String>(createdAt.value);
@ -3500,7 +3507,7 @@ typedef $$JobsTableCreateCompanionBuilder =
required int issueNumber, required int issueNumber,
required String fingerprint, required String fingerprint,
required String trigger, required String trigger,
required String status, required JobStatus status,
required String createdAt, required String createdAt,
required String updatedAt, required String updatedAt,
}); });
@ -3511,7 +3518,7 @@ typedef $$JobsTableUpdateCompanionBuilder =
Value<int> issueNumber, Value<int> issueNumber,
Value<String> fingerprint, Value<String> fingerprint,
Value<String> trigger, Value<String> trigger,
Value<String> status, Value<JobStatus> status,
Value<String> createdAt, Value<String> createdAt,
Value<String> updatedAt, Value<String> updatedAt,
}); });
@ -3549,9 +3556,10 @@ class $$JobsTableFilterComposer extends Composer<_$AppDatabase, $JobsTable> {
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<String> get status => $composableBuilder( ColumnWithTypeConverterFilters<JobStatus, JobStatus, String> get status =>
$composableBuilder(
column: $table.status, column: $table.status,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnWithTypeConverterFilters(column),
); );
ColumnFilters<String> get createdAt => $composableBuilder( ColumnFilters<String> get createdAt => $composableBuilder(
@ -3644,7 +3652,7 @@ class $$JobsTableAnnotationComposer
GeneratedColumn<String> get trigger => GeneratedColumn<String> get trigger =>
$composableBuilder(column: $table.trigger, builder: (column) => column); $composableBuilder(column: $table.trigger, builder: (column) => column);
GeneratedColumn<String> get status => GeneratedColumnWithTypeConverter<JobStatus, String> get status =>
$composableBuilder(column: $table.status, builder: (column) => column); $composableBuilder(column: $table.status, builder: (column) => column);
GeneratedColumn<String> get createdAt => GeneratedColumn<String> get createdAt =>
@ -3687,7 +3695,7 @@ class $$JobsTableTableManager
Value<int> issueNumber = const Value.absent(), Value<int> issueNumber = const Value.absent(),
Value<String> fingerprint = const Value.absent(), Value<String> fingerprint = const Value.absent(),
Value<String> trigger = const Value.absent(), Value<String> trigger = const Value.absent(),
Value<String> status = const Value.absent(), Value<JobStatus> status = const Value.absent(),
Value<String> createdAt = const Value.absent(), Value<String> createdAt = const Value.absent(),
Value<String> updatedAt = const Value.absent(), Value<String> updatedAt = const Value.absent(),
}) => JobsCompanion( }) => JobsCompanion(
@ -3707,7 +3715,7 @@ class $$JobsTableTableManager
required int issueNumber, required int issueNumber,
required String fingerprint, required String fingerprint,
required String trigger, required String trigger,
required String status, required JobStatus status,
required String createdAt, required String createdAt,
required String updatedAt, required String updatedAt,
}) => JobsCompanion.insert( }) => JobsCompanion.insert(

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'cli_responder.dart'; import 'cli_responder.dart';
import 'config.dart'; import 'config.dart';
@ -10,6 +12,8 @@ import 'github_client.dart';
import 'workspace_manager.dart'; import 'workspace_manager.dart';
class IssueAssistantApp { class IssueAssistantApp {
static final Logger _logger = Logger('code_work_spawner.app');
IssueAssistantApp._({ IssueAssistantApp._({
required this.config, required this.config,
required this.database, required this.database,
@ -35,7 +39,7 @@ class IssueAssistantApp {
database: database, database: database,
githubClient: GitHubClient(ghCommand: ghCommand), githubClient: GitHubClient(ghCommand: ghCommand),
responderRunner: CliResponderRunner(), responderRunner: CliResponderRunner(),
workspaceManager: WorkspaceManager(), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
); );
} }
@ -47,12 +51,15 @@ class IssueAssistantApp {
} }
Future<void> runOnce() async { Future<void> runOnce() async {
_log('starting poll cycle projects=${config.projects.length}');
for (final project in config.projects.values) { for (final project in config.projects.values) {
if (!project.issueAssistant.enabled) { if (!project.issueAssistant.enabled) {
_log('skip project=${project.key} reason=issue_assistant_disabled');
continue; continue;
} }
await _pollProject(project); await _pollProject(project);
} }
_log('poll cycle completed');
} }
Future<void> close() => database.close(); Future<void> close() => database.close();
@ -70,14 +77,22 @@ class IssueAssistantApp {
Future<void> _pollProject(ProjectConfig project) async { Future<void> _pollProject(ProjectConfig project) async {
final pollRunId = await database.startPollRun(project.key); final pollRunId = await database.startPollRun(project.key);
try { try {
final repoDirectory = Directory(project.path);
final repoExists = await repoDirectory.exists();
final projectState = await database.findProjectState(project.key); final projectState = await database.findProjectState(project.key);
final since = projectState?.lastSeenUpdatedAt == null final since = projectState?.lastSeenUpdatedAt == null
? null ? null
: DateTime.parse(projectState!.lastSeenUpdatedAt!); : DateTime.parse(projectState!.lastSeenUpdatedAt!);
_log(
'poll project=${project.key} repo=${project.repo} '
'path=${project.path} path_exists=$repoExists '
'since=${since?.toUtc().toIso8601String() ?? "initial"}',
);
final issues = await githubClient.fetchUpdatedIssues( final issues = await githubClient.fetchUpdatedIssues(
repo: project.repo, repo: project.repo,
since: since, since: since,
); );
_log('project=${project.key} fetched_issues=${issues.length}');
DateTime? latestSeen = since; DateTime? latestSeen = since;
for (final issue in issues) { for (final issue in issues) {
@ -93,12 +108,17 @@ class IssueAssistantApp {
lastSeenUpdatedAt: latestSeen, lastSeenUpdatedAt: latestSeen,
); );
await database.finishPollRun(pollRunId: pollRunId, success: true); await database.finishPollRun(pollRunId: pollRunId, success: true);
_log(
'poll project=${project.key} completed latest_seen='
'${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}',
);
} catch (error) { } catch (error) {
await database.finishPollRun( await database.finishPollRun(
pollRunId: pollRunId, pollRunId: pollRunId,
success: false, success: false,
error: error.toString(), error: error.toString(),
); );
_log('poll project=${project.key} failed error=$error');
rethrow; rethrow;
} }
} }
@ -114,16 +134,23 @@ class IssueAssistantApp {
final fingerprint = _fingerprintThread(thread); final fingerprint = _fingerprintThread(thread);
final record = await database.findIssueThread(project.key, issue.number); final record = await database.findIssueThread(project.key, issue.number);
if (record?.lastEvaluatedFingerprint == fingerprint) { if (record?.lastEvaluatedFingerprint == fingerprint) {
_log(
'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged',
);
return; return;
} }
final trigger = _determineTrigger(project, thread); final trigger = _determineTrigger(project, thread);
_log(
'evaluate issue=${project.key}#${issue.number} '
'comments=${thread.comments.length} trigger=$trigger',
);
final jobId = await database.createJob( final jobId = await database.createJob(
projectKey: project.key, projectKey: project.key,
issueNumber: issue.number, issueNumber: issue.number,
fingerprint: fingerprint, fingerprint: fingerprint,
trigger: trigger, trigger: trigger,
status: 'queued', status: JobStatus.queued,
); );
await database.upsertIssueThread( await database.upsertIssueThread(
@ -137,7 +164,7 @@ class IssueAssistantApp {
lastCommentUrl: record?.lastCommentUrl, lastCommentUrl: record?.lastCommentUrl,
); );
await database.updateJobStatus(jobId, 'running'); await database.updateJobStatus(jobId, JobStatus.running);
final forceReply = trigger == 'mention'; final forceReply = trigger == 'mention';
final prompt = _buildPrompt( final prompt = _buildPrompt(
project: project, project: project,
@ -145,6 +172,25 @@ class IssueAssistantApp {
trigger: trigger, trigger: trigger,
); );
if (project.issueAssistant.responders.isEmpty) {
_logError(
'issue=${project.key}#${issue.number} '
'skipped no_responders_configured',
);
await database.upsertIssueThread(
projectKey: project.key,
issueNumber: issue.number,
fingerprint: fingerprint,
updatedAt: issue.updatedAt,
lastEvaluatedFingerprint: fingerprint,
lastDecision: record?.lastDecision,
lastCommentId: record?.lastCommentId,
lastCommentUrl: record?.lastCommentUrl,
);
await database.updateJobStatus(jobId, JobStatus.skipped);
return;
}
ResponderResult? selectedResult; ResponderResult? selectedResult;
String? workspacePath; String? workspacePath;
Object? lastError; Object? lastError;
@ -154,6 +200,11 @@ class IssueAssistantApp {
workspacePath = needsWorktree workspacePath = needsWorktree
? await workspaceManager.createEphemeralWorktree(project.path) ? await workspaceManager.createEphemeralWorktree(project.path)
: project.path; : project.path;
_log(
'run responder=${responder.id} issue=${project.key}#${issue.number} '
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
'workspace=$workspacePath',
);
try { try {
final result = await responderRunner.run( final result = await responderRunner.run(
@ -174,10 +225,19 @@ class IssueAssistantApp {
stdout: result.rawStdout, stdout: result.rawStdout,
stderr: result.rawStderr, stderr: result.rawStderr,
); );
_log(
'responder=${responder.id}b issue=${project.key}#${issue.number}, '
'title="${thread.issue.title}", '
'decision=${result.decision}, mode=${result.mode}',
);
selectedResult = result; selectedResult = result;
break; break;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error',
);
await database.addExecutionRun( await database.addExecutionRun(
jobId: jobId, jobId: jobId,
responderId: responder.id, responderId: responder.id,
@ -196,7 +256,10 @@ class IssueAssistantApp {
} }
if (selectedResult == null) { if (selectedResult == null) {
await database.updateJobStatus(jobId, 'failed'); await database.updateJobStatus(jobId, JobStatus.failed);
_log(
'issue=${project.key}#${issue.number} failed no_responder_succeeded',
);
throw StateError( throw StateError(
'All responders failed for ${project.key}#${issue.number}: $lastError', 'All responders failed for ${project.key}#${issue.number}: $lastError',
); );
@ -213,7 +276,8 @@ class IssueAssistantApp {
lastCommentId: record?.lastCommentId, lastCommentId: record?.lastCommentId,
lastCommentUrl: record?.lastCommentUrl, lastCommentUrl: record?.lastCommentUrl,
); );
await database.updateJobStatus(jobId, 'completed'); await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} completed decision=no_reply');
return; return;
} }
@ -246,7 +310,8 @@ class IssueAssistantApp {
lastCommentId: posted.id, lastCommentId: posted.id,
lastCommentUrl: posted.url, lastCommentUrl: posted.url,
); );
await database.updateJobStatus(jobId, 'completed'); await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}');
} }
String _buildPrompt({ String _buildPrompt({
@ -320,4 +385,12 @@ class IssueAssistantApp {
}) { }) {
return '$markdown\n\n<!-- $tag:$fingerprint -->'; return '$markdown\n\n<!-- $tag:$fingerprint -->';
} }
void _log(String message) {
_logger.info(message);
}
void _logError(String message) {
_logger.severe(message);
}
} }

View File

@ -3,10 +3,24 @@ import 'dart:io';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class WorkspaceManager { class WorkspaceManager {
WorkspaceManager({this.worktreeRoot});
final String? worktreeRoot;
static const Set<String> _gitContextEnvironmentKeys = {
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_COMMON_DIR',
'GIT_DIR',
'GIT_IMPLICIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_WORK_TREE',
};
Future<String> createEphemeralWorktree(String projectPath) async { Future<String> createEphemeralWorktree(String projectPath) async {
final parent = await Directory.systemTemp.createTemp('cws-worktree-'); final parent = await _createWorktreeParent();
final workspacePath = p.join(parent.path, 'repo'); final workspacePath = p.join(parent.path, 'repo');
final result = await Process.run('git', [ final command = [
'-C', '-C',
projectPath, projectPath,
'worktree', 'worktree',
@ -14,20 +28,13 @@ class WorkspaceManager {
'--detach', '--detach',
workspacePath, workspacePath,
'HEAD', 'HEAD',
]); ];
final result = await _runGit(command);
if (result.exitCode != 0) { if (result.exitCode != 0) {
throw ProcessException( throw ProcessException(
'git', 'git',
[ command,
'-C',
projectPath,
'worktree',
'add',
'--detach',
workspacePath,
'HEAD',
],
'${result.stdout}\n${result.stderr}', '${result.stdout}\n${result.stderr}',
result.exitCode, result.exitCode,
); );
@ -36,8 +43,23 @@ class WorkspaceManager {
return workspacePath; return workspacePath;
} }
Future<Directory> _createWorktreeParent() async {
if (worktreeRoot == null) {
return Directory.systemTemp.createTemp('cws-worktree-');
}
final root = Directory(worktreeRoot!);
await root.create(recursive: true);
return Directory(
p.join(
root.path,
'cws-worktree-${DateTime.now().microsecondsSinceEpoch}',
),
)..createSync();
}
Future<void> disposeEphemeralWorktree(String workspacePath) async { Future<void> disposeEphemeralWorktree(String workspacePath) async {
final result = await Process.run('git', [ final result = await _runGit([
'-C', '-C',
workspacePath, workspacePath,
'worktree', 'worktree',
@ -59,4 +81,15 @@ class WorkspaceManager {
await tempRoot.delete(recursive: true); await tempRoot.delete(recursive: true);
} }
} }
Future<ProcessResult> _runGit(List<String> arguments) {
final environment = Map<String, String>.from(Platform.environment)
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
return Process.run(
'git',
arguments,
environment: environment,
includeParentEnvironment: false,
);
}
} }

View File

@ -14,6 +14,10 @@ dependencies:
path: ^1.9.0 path: ^1.9.0
sqlite3: ^2.9.3 sqlite3: ^2.9.3
yaml: ^3.1.3 yaml: ^3.1.3
result_dart: ^2.1.1
dotenv: ^4.2.0
logging: ^1.3.0
meta: ^1.16.0
dev_dependencies: dev_dependencies:
build_runner: ^2.6.0 build_runner: ^2.6.0

View File

@ -66,6 +66,161 @@ projects:
'project {{project_key}}', 'project {{project_key}}',
); );
}); });
/// ```gherkin
/// Scenario: Normalize legacy codex json flag
/// Given an orchestrator config that still uses the legacy codex --json-output flag
/// When loading the app config from disk
/// Then the responder args are normalized to the current codex --json flag
/// ```
test('Normalize legacy codex json flag', () async {
// Given an orchestrator config that still uses the legacy codex --json-output flag.
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
responders:
- id: codex
command: codex
args: ["exec", "--json-output"]
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the responder args are normalized to the current codex --json flag.
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
'exec',
'--json',
]);
});
/// ```gherkin
/// Scenario: Load AO subset config with shared worktree root
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
/// When loading the app config from disk
/// Then the app exposes the configured data and worktree directories
/// And each project inherits the supported shared defaults
/// ```
test('Load AO subset config with shared worktree root', () async {
// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults.
final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
dataDir: ~/.agent-orchestrator
worktreeDir: ~/.worktrees
defaults:
defaultBranch: trunk
sessionPrefix: ao
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the app exposes the configured data and worktree directories.
expect(
config.dataDir,
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
);
expect(
config.worktreeDir,
p.join(Platform.environment['HOME']!, '.worktrees'),
);
// And each project inherits the supported shared defaults.
expect(config.projects['sample'], isNotNull);
expect(config.projects['sample']!.defaultBranch, 'trunk');
expect(config.projects['sample']!.sessionPrefix, 'ao');
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
});
/// ```gherkin
/// Scenario: Reject unsupported AO fields
/// Given an AO style config that includes an unsupported field
/// When loading the app config from disk
/// Then loading fails with a clear config error
/// ```
test('Reject unsupported AO fields', () async {
// Given an AO style config that includes an unsupported field.
final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
worktreeDir: ~/.worktrees
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
tracker:
plugin: github
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear config error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('Unsupported config field: projects.sample.tracker'),
),
),
);
});
});
/// ```gherkin
/// Feature: Worktree management
///
/// As a maintainer running bug verification jobs
/// I want ephemeral git worktrees to respect the configured worktree root
/// So that temporary workspaces are created in a predictable location
/// ```
group('WorkspaceManager.createEphemeralWorktree', () {
/// ```gherkin
/// Scenario: Create and dispose a worktree under the configured root
/// Given a git repository and a dedicated worktree root directory
/// When creating and then disposing an ephemeral worktree
/// Then the worktree is created beneath the configured root
/// And cleanup removes the generated worktree directory without deleting the root
/// ```
test('Create and dispose a worktree under the configured root', () async {
// Given a git repository and a dedicated worktree root directory.
final sandbox = await Directory.systemTemp.createTemp(
'cws-worktree-root-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
final worktreeRoot = await Directory(
p.join(sandbox.path, 'worktrees'),
).create();
await _initGitRepo(repoDir);
final manager = WorkspaceManager(worktreeRoot: worktreeRoot.path);
// When creating and then disposing an ephemeral worktree.
final workspacePath = await manager.createEphemeralWorktree(repoDir.path);
final workspaceDirectory = Directory(workspacePath);
final parentDirectory = Directory(p.dirname(workspacePath));
await manager.disposeEphemeralWorktree(workspacePath);
// Then the worktree is created beneath the configured root.
expect(p.isWithin(worktreeRoot.path, workspacePath), isTrue);
// And cleanup removes the generated worktree directory without deleting the root.
expect(await workspaceDirectory.exists(), isFalse);
expect(await parentDirectory.exists(), isFalse);
expect(await worktreeRoot.exists(), isTrue);
});
}); });
/// ```gherkin /// ```gherkin
@ -268,6 +423,181 @@ projects:
expect(thread.lastCommentId, isNull); expect(thread.lastCommentId, isNull);
}, },
); );
/// ```gherkin
/// Scenario: Accept codex style jsonl responder output
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention
/// And a responder that emits codex style jsonl events instead of a single json object
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then it extracts the final agent message and posts one reply
/// ```
test('Accept codex style jsonl responder output', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 18,
'title': 'Need help',
'body': 'Please review the plan.',
'state': 'open',
'html_url': 'https://example.test/issues/18',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 180,
'body': '@helper can you respond?',
'html_url': 'https://example.test/issues/18#issuecomment-180',
'created_at': '2026-04-04T15:00:00Z',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {18: commentsData},
postCommentIssueNumber: 18,
ghLog: ghLog,
);
// And a responder that emits codex style jsonl events instead of a single json object.
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
await _writeJsonlResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown': 'Codex JSONL reply',
'summary': 'parsed event stream',
});
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
projects:
sample:
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
// When the app processes one polling cycle.
await app.runOnce();
await app.close();
// Then it extracts the final agent message and posts one reply.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/18/comments'),
)
.length,
2,
);
});
/// ```gherkin
/// Scenario: Skip issue evaluation when no responders are configured
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data with a comment that would otherwise be evaluated
/// And an AO subset config that enables the assistant without defining responders
/// When the app processes one polling cycle
/// Then it completes without throwing an error
/// And it records the issue thread as evaluated without a responder decision
/// And it stores the job status as skipped
/// ```
test('Skip issue evaluation when no responders are configured', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-no-responders-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data with a comment that would otherwise be evaluated.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 7,
'title': 'Need help',
'body': 'Please review this thread.',
'state': 'open',
'html_url': 'https://example.test/issues/7',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 70,
'body': '@helper can you take a look?',
'html_url': 'https://example.test/issues/7#issuecomment-70',
'created_at': '2026-04-04T14:00:00Z',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {7: commentsData},
);
// And an AO subset config that enables the assistant without defining responders.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
// When the app processes one polling cycle.
await app.runOnce();
final thread = await app.database.findIssueThread('sample', 7);
final jobs = await app.database.select(app.database.jobs).get();
await app.close();
// Then it completes without throwing an error.
expect(thread, isNotNull);
// And it records the issue thread as evaluated without a responder decision.
expect(thread!.lastDecision, isNull);
expect(thread.lastEvaluatedFingerprint, thread.fingerprint);
// And it stores the job status as skipped.
expect(jobs, hasLength(1));
expect(jobs.single.status, JobStatus.skipped);
});
}); });
} }
@ -353,23 +683,62 @@ JSON
await Process.run('chmod', ['+x', responderScript.path]); await Process.run('chmod', ['+x', responderScript.path]);
} }
Future<void> _writeJsonlResponderScript(
File responderScript,
Map<String, Object?> response,
) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
cat <<'JSON'
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
}
Future<void> _initGitRepo(Directory directory) async { Future<void> _initGitRepo(Directory directory) async {
await Process.run('git', ['init'], workingDirectory: directory.path); await _runGit(['init'], workingDirectory: directory.path);
await Process.run('git', [ await _runGit([
'config', 'config',
'user.email', 'user.email',
'test@example.com', 'test@example.com',
], workingDirectory: directory.path); ], workingDirectory: directory.path);
await Process.run('git', [ await _runGit([
'config', 'config',
'user.name', 'user.name',
'Test User', 'Test User',
], workingDirectory: directory.path); ], workingDirectory: directory.path);
await File(p.join(directory.path, 'README.md')).writeAsString('repo'); await File(p.join(directory.path, 'README.md')).writeAsString('repo');
await Process.run('git', ['add', '.'], workingDirectory: directory.path); await _runGit(['add', '.'], workingDirectory: directory.path);
await Process.run('git', [ await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
'commit', }
'-m',
'init', const Set<String> _gitContextEnvironmentKeys = {
], workingDirectory: directory.path); 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_COMMON_DIR',
'GIT_DIR',
'GIT_IMPLICIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_WORK_TREE',
};
Future<ProcessResult> _runGit(
List<String> arguments, {
required String workingDirectory,
}) {
final environment = Map<String, String>.from(Platform.environment)
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
return Process.run(
'git',
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: false,
);
} }