From 42b89719fddb23aa6b1b4d169c360a59d8326105 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 4 Apr 2026 22:12:03 +0800 Subject: [PATCH] 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 --- .github/workflows/unittest.yaml | 3 + .gitignore | 3 +- .vscode/extensions.json | 5 + README.md | 11 +- bin/code_work_spawner.dart | 29 +++ examples/simple-github.yaml | 24 ++ lefthook.yml | 4 +- lib/code_work_spawner.dart | 1 + lib/src/cli_responder.dart | 44 ++++ lib/src/config.dart | 165 +++++++++++-- lib/src/database.dart | 8 +- lib/src/database.g.dart | 84 ++++--- lib/src/issue_assistant_app.dart | 85 ++++++- lib/src/workspace_manager.dart | 59 +++-- pubspec.yaml | 4 + test/code_work_spawner_test.dart | 387 ++++++++++++++++++++++++++++++- 16 files changed, 830 insertions(+), 86 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 examples/simple-github.yaml diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index fcaf9c7..ba9d5fe 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -14,6 +14,9 @@ jobs: - name: Install dependencies run: dart pub get + - name: Build generated files + run: dart run build_runner build --delete-conflicting-outputs + - name: Static analysis run: dart analyze diff --git a/.gitignore b/.gitignore index 7dc9280..30cc889 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ agent-orchestrator.yaml # !/.agents !/.git !/.github +!/.vscode /skills/* !/skills/dart-gherkin-tests - +*.g.dart diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e04389 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "qwtel.sqlite-viewer" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index cd33737..ca5b51d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ defaults: responders: - id: codex command: codex - args: ["exec", "--json-output"] + args: ["exec", "--json"] timeout: 10m - id: opencode command: opencode @@ -66,8 +66,17 @@ projects: Project entries can override `issueAssistant` fields when a repo needs a 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 +```bash +dart run build_runner build +``` + Single poll cycle: ```bash diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index 701319b..70235d8 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -2,8 +2,11 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:logging/logging.dart'; Future main(List arguments) async { + _configureLogging(); + final parser = ArgParser() ..addFlag( 'once', @@ -36,6 +39,22 @@ Future main(List arguments) async { } 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( config: config, databasePath: results['db'] as String, @@ -53,3 +72,13 @@ Future main(List arguments) async { 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}', + ); + }); +} diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml new file mode 100644 index 0000000..3e61681 --- /dev/null +++ b/examples/simple-github.yaml @@ -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 diff --git a/lefthook.yml b/lefthook.yml index 96c0d8d..923993e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,5 +4,5 @@ pre-commit: run: dart format --output=none --set-exit-if-changed . analyze: run: dart analyze - test: - run: dart test + # test: + # run: dart test diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index 1383527..42788c8 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -2,3 +2,4 @@ export 'src/config.dart'; export 'src/database.dart'; export 'src/github_client.dart'; export 'src/issue_assistant_app.dart'; +export 'src/workspace_manager.dart'; diff --git a/lib/src/cli_responder.dart b/lib/src/cli_responder.dart index 6e59b39..e03a977 100644 --- a/lib/src/cli_responder.dart +++ b/lib/src/cli_responder.dart @@ -87,6 +87,11 @@ class CliResponderRunner { try { return (jsonDecode(trimmed) as Map).cast(); } on FormatException { + final eventJson = _decodeJsonlEvents(trimmed); + if (eventJson != null) { + return eventJson; + } + final first = trimmed.indexOf('{'); final last = trimmed.lastIndexOf('}'); if (first == -1 || last <= first) { @@ -96,6 +101,45 @@ class CliResponderRunner { return (jsonDecode(snippet) as Map).cast(); } } + + Map? _decodeJsonlEvents(String stdout) { + Map? 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(); + + 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(); + } + } + } on FormatException { + continue; + } + } + + if (lastEvent case {'text': final String text}) { + final trimmed = text.trim(); + if (trimmed.startsWith('{')) { + return (jsonDecode(trimmed) as Map).cast(); + } + } + + return null; + } } class ResponderResult { diff --git a/lib/src/config.dart b/lib/src/config.dart index d0d2f9a..82f5f2f 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -7,11 +7,15 @@ import 'package:yaml/yaml.dart'; class AppConfig { AppConfig({ required this.configPath, + required this.dataDir, + required this.worktreeDir, required this.defaults, required this.projects, }); final String configPath; + final String? dataDir; + final String? worktreeDir; final IssueAssistantConfig defaults; final Map projects; @@ -23,11 +27,80 @@ class AppConfig { 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 root) { + _validateKeys( + scope: 'config', + map: root, + allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'}, + ); + final defaultsMap = _asMap(root['defaults']); final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']); 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 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 = {}; + 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 _loadProjects( + Object? projectsNode, { + required IssueAssistantConfig defaultAssistant, + required Map defaultAssistantMap, + }) { if (projectsNode is! YamlMap || projectsNode.isEmpty) { throw const FormatException('projects must be a non-empty map.'); } @@ -39,16 +112,56 @@ class AppConfig { projects[key] = ProjectConfig.fromMap( key: key, map: value, - defaultAssistant: defaults, + defaultAssistant: defaultAssistant, defaultAssistantMap: defaultAssistantMap, ); } + return projects; + } - return AppConfig( - configPath: p.normalize(p.absolute(path)), - defaults: defaults, - projects: projects, - ); + static Map _loadAoSubsetProjects( + Object? projectsNode, { + required Map defaultsMap, + required IssueAssistantConfig defaultAssistant, + required Map defaultAssistantMap, + }) { + if (projectsNode is! YamlMap || projectsNode.isEmpty) { + throw const FormatException('projects must be a non-empty map.'); + } + + final projects = {}; + 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 = {...defaultsMap, ...value}; + projects[key] = ProjectConfig.fromMap( + key: key, + map: merged, + defaultAssistant: defaultAssistant, + defaultAssistantMap: defaultAssistantMap, + ); + } + return projects; + } + + static _ConfigDialect _detectDialect(Map root) { + final defaultsMap = _asMap(root['defaults']); + if (defaultsMap.containsKey('issueAssistant')) { + return _ConfigDialect.current; + } + return _ConfigDialect.aoSubset; } static Map _asMap(Object? value) { @@ -68,6 +181,18 @@ class AppConfig { throw FormatException('Expected map, got ${value.runtimeType}.'); } + + static void _validateKeys({ + required String scope, + required Map map, + required Set allowed, + }) { + for (final key in map.keys) { + if (!allowed.contains(key)) { + throw FormatException('Unsupported config field: $scope.$key'); + } + } + } } class ProjectConfig { @@ -237,11 +362,10 @@ class CliResponderConfig { throw FormatException('Responder "$id" must define command.'); } - final args = - (map['args'] as List?) - ?.map((item) => item.toString()) - .toList(growable: false) ?? - const []; + final args = _normalizeArgs( + (map['args'] as List?)?.map((item) => item.toString()) ?? + const [], + ); final env = AppConfig._asMap( map['env'], ).map((key, value) => MapEntry(key, value.toString())); @@ -257,6 +381,12 @@ class CliResponderConfig { ), ); } + + static List _normalizeArgs(Iterable args) { + return args + .map((arg) => arg == '--json-output' ? '--json' : arg) + .toList(growable: false); + } } enum AssistantCapability { @@ -298,6 +428,13 @@ String _expandHome(String path) { 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) { if (key.contains('-')) { return key @@ -319,3 +456,5 @@ String _deriveSessionPrefix(String key) { return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase(); } + +enum _ConfigDialect { current, aoSubset } diff --git a/lib/src/database.dart b/lib/src/database.dart index 0a5c85a..08ed0c2 100644 --- a/lib/src/database.dart +++ b/lib/src/database.dart @@ -6,6 +6,8 @@ import 'package:path/path.dart' as p; part 'database.g.dart'; +enum JobStatus { queued, running, completed, failed, skipped } + class ProjectStates extends Table { TextColumn get projectKey => text()(); TextColumn get repo => text()(); @@ -39,7 +41,7 @@ class Jobs extends Table { IntColumn get issueNumber => integer()(); TextColumn get fingerprint => text()(); TextColumn get trigger => text()(); - TextColumn get status => text()(); + TextColumn get status => textEnum()(); TextColumn get createdAt => text()(); TextColumn get updatedAt => text()(); } @@ -179,7 +181,7 @@ class AppDatabase extends _$AppDatabase { required int issueNumber, required String fingerprint, required String trigger, - required String status, + required JobStatus status, }) { final now = DateTime.now().toUtc().toIso8601String(); return into(jobs).insert( @@ -195,7 +197,7 @@ class AppDatabase extends _$AppDatabase { ); } - Future updateJobStatus(int jobId, String status) { + Future updateJobStatus(int jobId, JobStatus status) { return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write( JobsCompanion( status: Value(status), diff --git a/lib/src/database.g.dart b/lib/src/database.g.dart index eddc054..ec38a0b 100644 --- a/lib/src/database.g.dart +++ b/lib/src/database.g.dart @@ -1004,15 +1004,15 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> { type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _statusMeta = const VerificationMeta('status'); @override - late final GeneratedColumn status = GeneratedColumn( - 'status', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); + late final GeneratedColumnWithTypeConverter status = + GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($JobsTable.$converterstatus); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -1099,14 +1099,6 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> { } else if (isInserting) { 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')) { context.handle( _createdAtMeta, @@ -1152,10 +1144,12 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> { DriftSqlType.string, data['${effectivePrefix}trigger'], )!, - status: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}status'], - )!, + status: $JobsTable.$converterstatus.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}created_at'], @@ -1171,6 +1165,9 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> { $JobsTable createAlias(String alias) { return $JobsTable(attachedDatabase, alias); } + + static JsonTypeConverter2 $converterstatus = + const EnumNameConverter(JobStatus.values); } class Job extends DataClass implements Insertable { @@ -1179,7 +1176,7 @@ class Job extends DataClass implements Insertable { final int issueNumber; final String fingerprint; final String trigger; - final String status; + final JobStatus status; final String createdAt; final String updatedAt; const Job({ @@ -1200,7 +1197,11 @@ class Job extends DataClass implements Insertable { map['issue_number'] = Variable(issueNumber); map['fingerprint'] = Variable(fingerprint); map['trigger'] = Variable(trigger); - map['status'] = Variable(status); + { + map['status'] = Variable( + $JobsTable.$converterstatus.toSql(status), + ); + } map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); return map; @@ -1230,7 +1231,9 @@ class Job extends DataClass implements Insertable { issueNumber: serializer.fromJson(json['issueNumber']), fingerprint: serializer.fromJson(json['fingerprint']), trigger: serializer.fromJson(json['trigger']), - status: serializer.fromJson(json['status']), + status: $JobsTable.$converterstatus.fromJson( + serializer.fromJson(json['status']), + ), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), ); @@ -1244,7 +1247,9 @@ class Job extends DataClass implements Insertable { 'issueNumber': serializer.toJson(issueNumber), 'fingerprint': serializer.toJson(fingerprint), 'trigger': serializer.toJson(trigger), - 'status': serializer.toJson(status), + 'status': serializer.toJson( + $JobsTable.$converterstatus.toJson(status), + ), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), }; @@ -1256,7 +1261,7 @@ class Job extends DataClass implements Insertable { int? issueNumber, String? fingerprint, String? trigger, - String? status, + JobStatus? status, String? createdAt, String? updatedAt, }) => Job( @@ -1334,7 +1339,7 @@ class JobsCompanion extends UpdateCompanion { final Value issueNumber; final Value fingerprint; final Value trigger; - final Value status; + final Value status; final Value createdAt; final Value updatedAt; const JobsCompanion({ @@ -1353,7 +1358,7 @@ class JobsCompanion extends UpdateCompanion { required int issueNumber, required String fingerprint, required String trigger, - required String status, + required JobStatus status, required String createdAt, required String updatedAt, }) : projectKey = Value(projectKey), @@ -1391,7 +1396,7 @@ class JobsCompanion extends UpdateCompanion { Value? issueNumber, Value? fingerprint, Value? trigger, - Value? status, + Value? status, Value? createdAt, Value? updatedAt, }) { @@ -1426,7 +1431,9 @@ class JobsCompanion extends UpdateCompanion { map['trigger'] = Variable(trigger.value); } if (status.present) { - map['status'] = Variable(status.value); + map['status'] = Variable( + $JobsTable.$converterstatus.toSql(status.value), + ); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -3500,7 +3507,7 @@ typedef $$JobsTableCreateCompanionBuilder = required int issueNumber, required String fingerprint, required String trigger, - required String status, + required JobStatus status, required String createdAt, required String updatedAt, }); @@ -3511,7 +3518,7 @@ typedef $$JobsTableUpdateCompanionBuilder = Value issueNumber, Value fingerprint, Value trigger, - Value status, + Value status, Value createdAt, Value updatedAt, }); @@ -3549,10 +3556,11 @@ class $$JobsTableFilterComposer extends Composer<_$AppDatabase, $JobsTable> { builder: (column) => ColumnFilters(column), ); - ColumnFilters get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnFilters(column), - ); + ColumnWithTypeConverterFilters get status => + $composableBuilder( + column: $table.status, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, @@ -3644,7 +3652,7 @@ class $$JobsTableAnnotationComposer GeneratedColumn get trigger => $composableBuilder(column: $table.trigger, builder: (column) => column); - GeneratedColumn get status => + GeneratedColumnWithTypeConverter get status => $composableBuilder(column: $table.status, builder: (column) => column); GeneratedColumn get createdAt => @@ -3687,7 +3695,7 @@ class $$JobsTableTableManager Value issueNumber = const Value.absent(), Value fingerprint = const Value.absent(), Value trigger = const Value.absent(), - Value status = const Value.absent(), + Value status = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), }) => JobsCompanion( @@ -3707,7 +3715,7 @@ class $$JobsTableTableManager required int issueNumber, required String fingerprint, required String trigger, - required String status, + required JobStatus status, required String createdAt, required String updatedAt, }) => JobsCompanion.insert( diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index 0049f42..a9fd152 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; import 'cli_responder.dart'; import 'config.dart'; @@ -10,6 +12,8 @@ import 'github_client.dart'; import 'workspace_manager.dart'; class IssueAssistantApp { + static final Logger _logger = Logger('code_work_spawner.app'); + IssueAssistantApp._({ required this.config, required this.database, @@ -35,7 +39,7 @@ class IssueAssistantApp { database: database, githubClient: GitHubClient(ghCommand: ghCommand), responderRunner: CliResponderRunner(), - workspaceManager: WorkspaceManager(), + workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), ); } @@ -47,12 +51,15 @@ class IssueAssistantApp { } Future runOnce() async { + _log('starting poll cycle projects=${config.projects.length}'); for (final project in config.projects.values) { if (!project.issueAssistant.enabled) { + _log('skip project=${project.key} reason=issue_assistant_disabled'); continue; } await _pollProject(project); } + _log('poll cycle completed'); } Future close() => database.close(); @@ -70,14 +77,22 @@ class IssueAssistantApp { Future _pollProject(ProjectConfig project) async { final pollRunId = await database.startPollRun(project.key); try { + final repoDirectory = Directory(project.path); + final repoExists = await repoDirectory.exists(); final projectState = await database.findProjectState(project.key); final since = projectState?.lastSeenUpdatedAt == null ? null : 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( repo: project.repo, since: since, ); + _log('project=${project.key} fetched_issues=${issues.length}'); DateTime? latestSeen = since; for (final issue in issues) { @@ -93,12 +108,17 @@ class IssueAssistantApp { lastSeenUpdatedAt: latestSeen, ); await database.finishPollRun(pollRunId: pollRunId, success: true); + _log( + 'poll project=${project.key} completed latest_seen=' + '${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}', + ); } catch (error) { await database.finishPollRun( pollRunId: pollRunId, success: false, error: error.toString(), ); + _log('poll project=${project.key} failed error=$error'); rethrow; } } @@ -114,16 +134,23 @@ class IssueAssistantApp { final fingerprint = _fingerprintThread(thread); final record = await database.findIssueThread(project.key, issue.number); if (record?.lastEvaluatedFingerprint == fingerprint) { + _log( + 'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged', + ); return; } final trigger = _determineTrigger(project, thread); + _log( + 'evaluate issue=${project.key}#${issue.number} ' + 'comments=${thread.comments.length} trigger=$trigger', + ); final jobId = await database.createJob( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, trigger: trigger, - status: 'queued', + status: JobStatus.queued, ); await database.upsertIssueThread( @@ -137,7 +164,7 @@ class IssueAssistantApp { lastCommentUrl: record?.lastCommentUrl, ); - await database.updateJobStatus(jobId, 'running'); + await database.updateJobStatus(jobId, JobStatus.running); final forceReply = trigger == 'mention'; final prompt = _buildPrompt( project: project, @@ -145,6 +172,25 @@ class IssueAssistantApp { 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; String? workspacePath; Object? lastError; @@ -154,6 +200,11 @@ class IssueAssistantApp { workspacePath = needsWorktree ? await workspaceManager.createEphemeralWorktree(project.path) : project.path; + _log( + 'run responder=${responder.id} issue=${project.key}#${issue.number} ' + 'mode_hint=${needsWorktree ? "bug_verification" : "planning"} ' + 'workspace=$workspacePath', + ); try { final result = await responderRunner.run( @@ -174,10 +225,19 @@ class IssueAssistantApp { stdout: result.rawStdout, 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; break; } catch (error) { lastError = error; + _log( + 'responder=${responder.id} issue=${project.key}#${issue.number} ' + 'failed error=$error', + ); await database.addExecutionRun( jobId: jobId, responderId: responder.id, @@ -196,7 +256,10 @@ class IssueAssistantApp { } 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( 'All responders failed for ${project.key}#${issue.number}: $lastError', ); @@ -213,7 +276,8 @@ class IssueAssistantApp { lastCommentId: record?.lastCommentId, 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; } @@ -246,7 +310,8 @@ class IssueAssistantApp { lastCommentId: posted.id, 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({ @@ -320,4 +385,12 @@ class IssueAssistantApp { }) { return '$markdown\n\n'; } + + void _log(String message) { + _logger.info(message); + } + + void _logError(String message) { + _logger.severe(message); + } } diff --git a/lib/src/workspace_manager.dart b/lib/src/workspace_manager.dart index e155eae..5414179 100644 --- a/lib/src/workspace_manager.dart +++ b/lib/src/workspace_manager.dart @@ -3,10 +3,24 @@ import 'dart:io'; import 'package:path/path.dart' as p; class WorkspaceManager { + WorkspaceManager({this.worktreeRoot}); + + final String? worktreeRoot; + static const Set _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 createEphemeralWorktree(String projectPath) async { - final parent = await Directory.systemTemp.createTemp('cws-worktree-'); + final parent = await _createWorktreeParent(); final workspacePath = p.join(parent.path, 'repo'); - final result = await Process.run('git', [ + final command = [ '-C', projectPath, 'worktree', @@ -14,20 +28,13 @@ class WorkspaceManager { '--detach', workspacePath, 'HEAD', - ]); + ]; + final result = await _runGit(command); if (result.exitCode != 0) { throw ProcessException( 'git', - [ - '-C', - projectPath, - 'worktree', - 'add', - '--detach', - workspacePath, - 'HEAD', - ], + command, '${result.stdout}\n${result.stderr}', result.exitCode, ); @@ -36,8 +43,23 @@ class WorkspaceManager { return workspacePath; } + Future _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 disposeEphemeralWorktree(String workspacePath) async { - final result = await Process.run('git', [ + final result = await _runGit([ '-C', workspacePath, 'worktree', @@ -59,4 +81,15 @@ class WorkspaceManager { await tempRoot.delete(recursive: true); } } + + Future _runGit(List arguments) { + final environment = Map.from(Platform.environment) + ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); + return Process.run( + 'git', + arguments, + environment: environment, + includeParentEnvironment: false, + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index c4845b8..6ca045f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,10 @@ dependencies: path: ^1.9.0 sqlite3: ^2.9.3 yaml: ^3.1.3 + result_dart: ^2.1.1 + dotenv: ^4.2.0 + logging: ^1.3.0 + meta: ^1.16.0 dev_dependencies: build_runner: ^2.6.0 diff --git a/test/code_work_spawner_test.dart b/test/code_work_spawner_test.dart index a38a8e8..799bedf 100644 --- a/test/code_work_spawner_test.dart +++ b/test/code_work_spawner_test.dart @@ -66,6 +66,161 @@ projects: '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().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 @@ -268,6 +423,181 @@ projects: 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]); } +Future _writeJsonlResponderScript( + File responderScript, + Map 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 _initGitRepo(Directory directory) async { - await Process.run('git', ['init'], workingDirectory: directory.path); - await Process.run('git', [ + await _runGit(['init'], workingDirectory: directory.path); + await _runGit([ 'config', 'user.email', 'test@example.com', ], workingDirectory: directory.path); - await Process.run('git', [ + await _runGit([ 'config', 'user.name', 'Test User', ], workingDirectory: directory.path); await File(p.join(directory.path, 'README.md')).writeAsString('repo'); - await Process.run('git', ['add', '.'], workingDirectory: directory.path); - await Process.run('git', [ - 'commit', - '-m', - 'init', - ], workingDirectory: directory.path); + await _runGit(['add', '.'], workingDirectory: directory.path); + await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path); +} + +const Set _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 _runGit( + List arguments, { + required String workingDirectory, +}) { + final environment = Map.from(Platform.environment) + ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); + return Process.run( + 'git', + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: false, + ); }