@aion0/forge 0.10.84 → 0.10.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +5 -5
- package/app/api/schedules/[id]/route.ts +27 -20
- package/app/api/schedules/extract/route.ts +23 -16
- package/app/chat/page.tsx +7 -1231
- package/components/Dashboard.tsx +37 -15
- package/components/HomeView.tsx +142 -0
- package/components/PipelineActivityPanel.tsx +327 -0
- package/components/SchedulesView.tsx +7 -1
- package/components/WebChatPanel.tsx +1253 -0
- package/lib/chat/agent-loop.ts +10 -11
- package/lib/pipeline.ts +43 -1
- package/lib/projects.ts +154 -1
- package/package.json +1 -1
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -360,12 +360,12 @@ function buildConnectorCatalog(openSet: Set<string>): string[] {
|
|
|
360
360
|
return lines;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
function buildSystemPrompt(
|
|
363
|
+
async function buildSystemPrompt(
|
|
364
364
|
openConnectorTools: LlmTool[],
|
|
365
365
|
openSet: Set<string>,
|
|
366
366
|
builtinDefs: typeof BUILTIN_TOOL_DEFS,
|
|
367
367
|
sessionSystemPrompt: string | null,
|
|
368
|
-
): string {
|
|
368
|
+
): Promise<string> {
|
|
369
369
|
const now = new Date().toISOString();
|
|
370
370
|
|
|
371
371
|
// Inject a brief Forge context block (project names only) so the LLM can
|
|
@@ -375,7 +375,7 @@ function buildSystemPrompt(
|
|
|
375
375
|
// names are cheap enough to ship every turn.
|
|
376
376
|
let projectNames: string[] = [];
|
|
377
377
|
try {
|
|
378
|
-
const { scanProjects } =
|
|
378
|
+
const { scanProjects } = await import('../projects');
|
|
379
379
|
projectNames = scanProjects().map((p) => p.name);
|
|
380
380
|
} catch { /* projects roots not configured / read failed — omit */ }
|
|
381
381
|
|
|
@@ -751,8 +751,8 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
751
751
|
}
|
|
752
752
|
|
|
753
753
|
const sessionSystemPrompt = session.system_prompt;
|
|
754
|
-
function buildSystem(openTools: LlmTool[], openSet: Set<string>): string {
|
|
755
|
-
let s = buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
|
|
754
|
+
async function buildSystem(openTools: LlmTool[], openSet: Set<string>): Promise<string> {
|
|
755
|
+
let s = await buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
|
|
756
756
|
if (narrowDirective) s += narrowDirective;
|
|
757
757
|
return s;
|
|
758
758
|
}
|
|
@@ -766,7 +766,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
766
766
|
// per-iteration when the profile's maxInputTokens is tight. The
|
|
767
767
|
// assembled string is recomputed each iter (after open-set + memory
|
|
768
768
|
// trim). `system` here holds the base (no memory section).
|
|
769
|
-
let system = buildSystem(openConnectorTools, openSet);
|
|
769
|
+
let system = await buildSystem(openConnectorTools, openSet);
|
|
770
770
|
if (memStore.enabled) {
|
|
771
771
|
const searchHint = memStore.kind === 'local'
|
|
772
772
|
? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
|
|
@@ -848,7 +848,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
848
848
|
openSet = newOpenSet;
|
|
849
849
|
openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
|
|
850
850
|
allTools = [...builtinToolDefs, ...openConnectorTools];
|
|
851
|
-
system = buildSystem(openConnectorTools, openSet);
|
|
851
|
+
system = await buildSystem(openConnectorTools, openSet);
|
|
852
852
|
console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
|
|
853
853
|
}
|
|
854
854
|
|
|
@@ -1071,10 +1071,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
1071
1071
|
const leftovers = consumeNotes(args.sessionId);
|
|
1072
1072
|
endTurn(args.sessionId);
|
|
1073
1073
|
if (leftovers.length > 0) {
|
|
1074
|
-
// Lazy
|
|
1075
|
-
// a static import here would be circular.
|
|
1076
|
-
|
|
1077
|
-
const { enqueueChatInput } = require('./input-queue') as typeof import('./input-queue');
|
|
1074
|
+
// Lazy dynamic import: input-queue imports runTurn from this module —
|
|
1075
|
+
// a static import here would be circular. ESM requires await import (no require).
|
|
1076
|
+
const { enqueueChatInput } = await import('./input-queue');
|
|
1078
1077
|
for (const text of leftovers) {
|
|
1079
1078
|
enqueueChatInput({
|
|
1080
1079
|
sessionId: args.sessionId,
|
package/lib/pipeline.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
@@ -172,6 +172,16 @@ export interface Workflow {
|
|
|
172
172
|
/** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
|
|
173
173
|
* so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
|
|
174
174
|
git_push?: boolean;
|
|
175
|
+
/** Declares the pipeline needs a real git checkout to operate (e.g. it will
|
|
176
|
+
* cd into the project and run `git`, `glab`, build/test commands). When
|
|
177
|
+
* true, startPipeline runs the strict resolver (resolveProjectStrict) and
|
|
178
|
+
* aborts the whole run with a clear error if no project can be resolved —
|
|
179
|
+
* instead of silently falling back to scratch, which causes downstream
|
|
180
|
+
* shell nodes to crash with "fatal: not a git repository".
|
|
181
|
+
*
|
|
182
|
+
* Default: false (preserves the scratch fallback for connector-only / chat
|
|
183
|
+
* pipelines that don't touch a repo). */
|
|
184
|
+
requires_real_project?: boolean;
|
|
175
185
|
/** Default execution backend for every node's task. 'tmux' runs interactive
|
|
176
186
|
* claude in a per-node tmux session (subscription billing); 'headless' /
|
|
177
187
|
* omitted uses default `claude -p`. Per-node `backend:` overrides this. */
|
|
@@ -517,6 +527,14 @@ export function parseWorkflow(raw: string): Workflow {
|
|
|
517
527
|
for_each,
|
|
518
528
|
conversation,
|
|
519
529
|
backend: parsed.backend === 'tmux' || parsed.backend === 'headless' ? parsed.backend : undefined,
|
|
530
|
+
// Top-level boolean flag — must be explicitly passed through; the return is
|
|
531
|
+
// a literal, so any field omitted here is silently dropped from the parsed
|
|
532
|
+
// workflow. NOTE: `git_push` has the SAME latent issue (it's declared in
|
|
533
|
+
// the interface + read at startPipeline but never parsed here, so its OTP
|
|
534
|
+
// preflight has never actually fired). Left as-is on purpose: wiring it up
|
|
535
|
+
// would newly enable the preflight on existing fortinet pipelines, an
|
|
536
|
+
// unrelated behavior change that belongs in its own commit.
|
|
537
|
+
requires_real_project: parsed.requires_real_project === true,
|
|
520
538
|
};
|
|
521
539
|
}
|
|
522
540
|
|
|
@@ -1081,6 +1099,30 @@ export function startPipeline(
|
|
|
1081
1099
|
forEach: forEachState,
|
|
1082
1100
|
};
|
|
1083
1101
|
|
|
1102
|
+
// Strict project resolution — only for pipelines that declared they need a
|
|
1103
|
+
// real git checkout. MUST run BEFORE ensurePipelineTmpDir: the lenient
|
|
1104
|
+
// resolver inside that helper falls back to scratch (building tmp_dir in the
|
|
1105
|
+
// wrong place) and downstream `git` / `glab` commands then crash with
|
|
1106
|
+
// "fatal: not a git repository". The strict resolver instead clones/locates
|
|
1107
|
+
// the real repo (or returns a structured error we surface here), and seeds
|
|
1108
|
+
// input.project so the tmp_dir lands inside that repo.
|
|
1109
|
+
if (workflow.requires_real_project) {
|
|
1110
|
+
const r = resolveProjectStrict(input.project || '');
|
|
1111
|
+
if ('error' in r) {
|
|
1112
|
+
pipeline.status = 'failed';
|
|
1113
|
+
pipeline.error = r.message;
|
|
1114
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1115
|
+
savePipeline(pipeline);
|
|
1116
|
+
finalizePipeline(pipeline);
|
|
1117
|
+
return pipeline;
|
|
1118
|
+
}
|
|
1119
|
+
// Seed the resolved project name into input so ensurePipelineTmpDir and
|
|
1120
|
+
// every node's lenient resolveOrCloneProject hit it immediately as
|
|
1121
|
+
// 'existing' (no redundant scan/clone). pipeline.input === input here
|
|
1122
|
+
// (same ref from construction), so an in-place mutation updates both.
|
|
1123
|
+
if (r.project.name) input.project = r.project.name;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1084
1126
|
ensurePipelineTmpDir(pipeline);
|
|
1085
1127
|
|
|
1086
1128
|
// Git OTP/2FA preflight — abort the whole run up front so a push wall never
|
package/lib/projects.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync, accessSync, constants as fsConstants } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join, resolve, isAbsolute, parse } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -231,6 +231,159 @@ export interface ResolveResult {
|
|
|
231
231
|
clone_url?: string;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ─── Container mode + auto-root ───────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const CONTAINER_DEFAULT_ROOT = '/data/project';
|
|
237
|
+
|
|
238
|
+
/** True when Forge is running inside a Docker/container deployment.
|
|
239
|
+
* Two-channel detection (per user choice C):
|
|
240
|
+
* 1. FORGE_CONTAINER=1 env var (set by entrypoint)
|
|
241
|
+
* 2. /data/project exists AND is writable (probe; covers ad-hoc mounts)
|
|
242
|
+
* Either signal is enough. */
|
|
243
|
+
export function detectContainerMode(): boolean {
|
|
244
|
+
if (process.env.FORGE_CONTAINER === '1') return true;
|
|
245
|
+
try {
|
|
246
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
247
|
+
return true;
|
|
248
|
+
} catch { return false; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** In container mode, register `/data/project` as a project root if no roots
|
|
252
|
+
* are configured yet. Idempotent: returns the path that was added (or
|
|
253
|
+
* already present), or null when we're not in container mode / the path
|
|
254
|
+
* isn't writable. Auto-add (user choice 3) so subsequent runs use it without
|
|
255
|
+
* re-triggering this path.
|
|
256
|
+
*
|
|
257
|
+
* Persists to settings so the path also appears in Settings → Projects. */
|
|
258
|
+
export function ensureContainerRoot(): string | null {
|
|
259
|
+
if (!detectContainerMode()) return null;
|
|
260
|
+
try {
|
|
261
|
+
mkdirSync(CONTAINER_DEFAULT_ROOT, { recursive: true });
|
|
262
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
263
|
+
} catch { return null; }
|
|
264
|
+
const settings = loadSettings();
|
|
265
|
+
const roots = settings.projectRoots || [];
|
|
266
|
+
if (roots.includes(CONTAINER_DEFAULT_ROOT)) return CONTAINER_DEFAULT_ROOT;
|
|
267
|
+
settings.projectRoots = [...roots, CONTAINER_DEFAULT_ROOT];
|
|
268
|
+
saveSettings(settings);
|
|
269
|
+
invalidateProjectScan();
|
|
270
|
+
return CONTAINER_DEFAULT_ROOT;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Strict resolver (for pipelines that declare requires_real_project) ───
|
|
274
|
+
|
|
275
|
+
export type StrictResolveError =
|
|
276
|
+
| 'no_repo_specified'
|
|
277
|
+
| 'clone_failed';
|
|
278
|
+
|
|
279
|
+
export interface StrictResolveResult {
|
|
280
|
+
project: LocalProject;
|
|
281
|
+
source: 'existing' | 'gitlab-cloned';
|
|
282
|
+
clone_url?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Strict project resolver — used by pipelines that declare
|
|
287
|
+
* `requires_real_project: true`. Differs from resolveOrCloneProject in that
|
|
288
|
+
* it NEVER falls back to scratch; on any failure it returns a structured
|
|
289
|
+
* error so the caller (startPipeline) can abort the run with a clear message
|
|
290
|
+
* instead of letting git commands inside the task crash with
|
|
291
|
+
* "fatal: not a git repository".
|
|
292
|
+
*
|
|
293
|
+
* Flow (matches the user's spec):
|
|
294
|
+
* 1. Existing project by name / owner-repo / fuzzy → use it
|
|
295
|
+
* 2. Otherwise figure out the target repo URL:
|
|
296
|
+
* - input name contains '/' → treat as owner/repo
|
|
297
|
+
* - else gitlab connector default_project_path
|
|
298
|
+
* - else → error('no_repo_specified')
|
|
299
|
+
* 3. Re-scan locally for a checkout whose origin matches the target → use it
|
|
300
|
+
* 4. Clone via tryGitlabClone — writes to the Forge-managed cache
|
|
301
|
+
* (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
|
|
302
|
+
* need a configured projectRoot. We best-effort register the container
|
|
303
|
+
* root (/data/project) so manually-placed checkouts there get scanned,
|
|
304
|
+
* but never block the clone on it.
|
|
305
|
+
* - gitlab connector missing → error('no_repo_specified')
|
|
306
|
+
* - clone failed → error('clone_failed')
|
|
307
|
+
*/
|
|
308
|
+
export function resolveProjectStrict(name: string | undefined): StrictResolveResult | { error: StrictResolveError; message: string } {
|
|
309
|
+
const trimmed = (name || '').trim();
|
|
310
|
+
|
|
311
|
+
// Step 1: existing match — reuse the lenient resolver's match logic by
|
|
312
|
+
// delegating, but discard its scratch fallback.
|
|
313
|
+
if (trimmed) {
|
|
314
|
+
const hit = getProjectInfo(trimmed);
|
|
315
|
+
if (hit) return { project: hit, source: 'existing' };
|
|
316
|
+
|
|
317
|
+
const projects = scanProjects();
|
|
318
|
+
if (trimmed.includes('/')) {
|
|
319
|
+
const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
320
|
+
const wantBase = want.split('/').pop()!;
|
|
321
|
+
const byRepo = projects.filter((p) => p.repo && p.repo === want);
|
|
322
|
+
if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
|
|
323
|
+
const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
|
|
324
|
+
if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
|
|
325
|
+
}
|
|
326
|
+
const base = trimmed.split('/').pop()!.trim();
|
|
327
|
+
const ownerRepo = trimmed.replace(/\//g, '-');
|
|
328
|
+
const cands = projects.filter((p) =>
|
|
329
|
+
p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
|
|
330
|
+
if (cands.length === 1) return { project: cands[0], source: 'existing' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Step 2: derive target repo path
|
|
334
|
+
const gl = readGitlabConnector();
|
|
335
|
+
const targetPath = trimmed && trimmed.includes('/')
|
|
336
|
+
? trimmed
|
|
337
|
+
: (gl?.default_project_path || '').trim();
|
|
338
|
+
if (!targetPath) {
|
|
339
|
+
return {
|
|
340
|
+
error: 'no_repo_specified',
|
|
341
|
+
message: trimmed
|
|
342
|
+
? `Could not resolve project "${trimmed}". Pass an owner/repo path, or set GitLab connector's default_project_path.`
|
|
343
|
+
: 'No repository specified. Set `project: owner/repo` in pipeline input, or set GitLab connector\'s default_project_path.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Step 3: scan locally by origin remote
|
|
348
|
+
if (gl) {
|
|
349
|
+
const projects = scanProjects();
|
|
350
|
+
const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
351
|
+
const wantBase = want.split('/').pop()!;
|
|
352
|
+
const localHit =
|
|
353
|
+
projects.find((p) => p.repo && p.repo === want) ||
|
|
354
|
+
projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
|
|
355
|
+
projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
|
|
356
|
+
if (localHit) return { project: localHit, source: 'existing' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Step 4: clone. tryGitlabClone writes to <dataDir>/cloned-projects (always
|
|
360
|
+
// writable) — it does NOT use projectRoots, so there's no "no project root"
|
|
361
|
+
// failure here: as long as the gitlab connector is configured, a clone has
|
|
362
|
+
// somewhere to land. Best-effort register the container root so manual
|
|
363
|
+
// checkouts under /data/project are discoverable on the next scan; ignore
|
|
364
|
+
// its result — it must never block the clone.
|
|
365
|
+
ensureContainerRoot();
|
|
366
|
+
|
|
367
|
+
if (!gl) {
|
|
368
|
+
return {
|
|
369
|
+
error: 'no_repo_specified',
|
|
370
|
+
message: `GitLab connector is not configured; cannot clone "${targetPath}". Install + configure the GitLab connector first.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const cloned = tryGitlabClone(targetPath);
|
|
374
|
+
if (!cloned) {
|
|
375
|
+
return {
|
|
376
|
+
error: 'clone_failed',
|
|
377
|
+
message: `Failed to clone ${gl.base_url}/${targetPath}. Check GitLab connectivity, credentials, and that the path exists.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
project: cloned,
|
|
382
|
+
source: 'gitlab-cloned',
|
|
383
|
+
clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
234
387
|
/**
|
|
235
388
|
* Pick a writable directory to clone into, in priority order:
|
|
236
389
|
* 1. settings.projectRoots[0] — what the user already trusts
|
package/package.json
CHANGED