@aion0/forge 0.10.85 → 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 CHANGED
@@ -1,11 +1,11 @@
1
- # Forge v0.10.85
1
+ # Forge v0.10.86
2
2
 
3
3
  Released: 2026-06-16
4
4
 
5
- ## Changes since v0.10.84
5
+ ## Changes since v0.10.85
6
6
 
7
7
  ### Other
8
- - feat(home): three-pane Home view with web chat + activity rail
8
+ - fix(schedules): pause/resume + extractor openai support
9
9
 
10
10
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.84...v0.10.85
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.85...v0.10.86
@@ -116,29 +116,36 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
116
116
  }
117
117
  patch.action_config = body.action_config;
118
118
  }
119
- const effectiveActionKind = patch.action_kind ?? existing.action_kind;
120
- const effectiveActionConfig = patch.action_config ?? existing.action_config ?? {};
121
- if (effectiveActionKind === 'chat') {
122
- const sid = typeof effectiveActionConfig.session_id === 'string' ? effectiveActionConfig.session_id.trim() : '';
123
- if (!sid) return NextResponse.json({ error: 'action_kind=chat requires action_config.session_id' }, { status: 400 });
124
- }
125
- if (effectiveActionKind === 'email') {
126
- const toRaw = effectiveActionConfig.to;
127
- const to = Array.isArray(toRaw)
128
- ? toRaw.filter((x) => typeof x === 'string' && x.trim())
129
- : (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
130
- if (to.length === 0) {
131
- return NextResponse.json({ error: 'action_kind=email requires action_config.to (recipient address or array)' }, { status: 400 });
119
+ // Cross-field validation runs ONLY for the section being patched.
120
+ // Toggling unrelated fields (e.g. `enabled` from the Pause/Resume button)
121
+ // must not re-validate sections the user isn't touching — a schedule with a
122
+ // pre-existing quirk in its action_config would otherwise refuse to pause.
123
+ if (patch.action_kind !== undefined || patch.action_config !== undefined) {
124
+ const effectiveActionKind = patch.action_kind ?? existing.action_kind;
125
+ const effectiveActionConfig = patch.action_config ?? existing.action_config ?? {};
126
+ if (effectiveActionKind === 'chat') {
127
+ const sid = typeof effectiveActionConfig.session_id === 'string' ? effectiveActionConfig.session_id.trim() : '';
128
+ if (!sid) return NextResponse.json({ error: 'action_kind=chat requires action_config.session_id' }, { status: 400 });
129
+ }
130
+ if (effectiveActionKind === 'email') {
131
+ const toRaw = effectiveActionConfig.to;
132
+ const to = Array.isArray(toRaw)
133
+ ? toRaw.filter((x) => typeof x === 'string' && x.trim())
134
+ : (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
135
+ if (to.length === 0) {
136
+ return NextResponse.json({ error: 'action_kind=email requires action_config.to (recipient address or array)' }, { status: 400 });
137
+ }
132
138
  }
133
139
  }
134
140
 
135
- // Cross-field consistency
136
- const effectiveKind = patch.schedule_kind ?? existing.schedule_kind;
137
- if (effectiveKind === 'cron' && !(patch.schedule_cron ?? existing.schedule_cron)) {
138
- return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
139
- }
140
- if (effectiveKind === 'once' && !(patch.schedule_at ?? existing.schedule_at)) {
141
- return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
141
+ if (patch.schedule_kind !== undefined || patch.schedule_cron !== undefined || patch.schedule_at !== undefined) {
142
+ const effectiveKind = patch.schedule_kind ?? existing.schedule_kind;
143
+ if (effectiveKind === 'cron' && !(patch.schedule_cron ?? existing.schedule_cron)) {
144
+ return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
145
+ }
146
+ if (effectiveKind === 'once' && !(patch.schedule_at ?? existing.schedule_at)) {
147
+ return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
148
+ }
142
149
  }
143
150
 
144
151
  updateSchedule(id, patch);
@@ -19,6 +19,8 @@ import { inferAdapter, pickApiKey, pickBaseUrl } from '@/lib/chat/agent-loop';
19
19
  import { makeAnthropicClient } from '@/lib/chat/llm/anthropic';
20
20
  import { listAgents } from '@/lib/agents';
21
21
  import { scanProjects } from '@/lib/projects';
22
+ import { createOpenAI } from '@ai-sdk/openai';
23
+ import { generateText } from 'ai';
22
24
 
23
25
  interface Extracted {
24
26
  name: string;
@@ -116,15 +118,9 @@ export async function POST(req: Request) {
116
118
  ? profiles[preferredId]
117
119
  : candidates[0]![1];
118
120
  const adapter = inferAdapter(profile.provider);
119
- if (adapter !== 'anthropic') {
120
- return NextResponse.json({
121
- ok: false,
122
- error: 'extractor currently supports anthropic profiles only (openai is a follow-up)',
123
- }, { status: 400 });
124
- }
125
121
  const apiKey = pickApiKey(profile, adapter);
126
122
  const baseUrl = pickBaseUrl(profile, adapter);
127
- const model = profile.model || 'claude-sonnet-4-6';
123
+ const model = profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
128
124
 
129
125
  const cliAgents = listAgents().filter((a: any) => a.backendType !== 'api');
130
126
  const availableAgentIds = cliAgents.map((a: any) => a.id);
@@ -134,15 +130,26 @@ export async function POST(req: Request) {
134
130
 
135
131
  let raw = '';
136
132
  try {
137
- const client = makeAnthropicClient(apiKey, baseUrl);
138
- const resp = await client.messages.create({
139
- model,
140
- max_tokens: 1500,
141
- system,
142
- messages: [{ role: 'user', content: text }],
143
- });
144
- for (const block of resp.content) {
145
- if (block.type === 'text') raw += block.text;
133
+ if (adapter === 'anthropic') {
134
+ const client = makeAnthropicClient(apiKey, baseUrl);
135
+ const resp = await client.messages.create({
136
+ model,
137
+ max_tokens: 1500,
138
+ system,
139
+ messages: [{ role: 'user', content: text }],
140
+ });
141
+ for (const block of resp.content) {
142
+ if (block.type === 'text') raw += block.text;
143
+ }
144
+ } else {
145
+ // OpenAI / OpenAI-compatible — non-streaming one-shot via @ai-sdk/openai.
146
+ const provider = createOpenAI({ apiKey, baseURL: baseUrl });
147
+ const resp = await generateText({
148
+ model: provider.chat(model),
149
+ system,
150
+ messages: [{ role: 'user', content: text }],
151
+ });
152
+ raw = resp.text;
146
153
  }
147
154
  } catch (e: any) {
148
155
  return NextResponse.json({
@@ -145,11 +145,17 @@ export default function SchedulesView({ onViewPipeline }: Props) {
145
145
 
146
146
  async function togglePaused(s: Schedule) {
147
147
  try {
148
- await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
148
+ const res = await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
149
149
  method: 'PATCH',
150
150
  headers: { 'Content-Type': 'application/json' },
151
151
  body: JSON.stringify({ enabled: !s.enabled }),
152
152
  });
153
+ if (!res.ok) {
154
+ // Surface backend validation errors — otherwise the button looks dead.
155
+ const body = await res.json().catch(() => ({}));
156
+ setErr(`Toggle failed (${res.status}): ${body?.error || res.statusText}`);
157
+ return;
158
+ }
153
159
  void refresh();
154
160
  } catch (e) { setErr(`Toggle failed: ${e instanceof Error ? e.message : String(e)}`); }
155
161
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.85",
3
+ "version": "0.10.86",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {