@saadnvd1/agent-os 0.3.509 → 0.3.512

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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What the start form needs in one request: the agents and their models, the
3
+ * models a task can run on, the machines, the default branch and whether a
4
+ * task plans first. Built from the helpers the web form already uses; never
5
+ * a provider's address or key reference, nor a machine's ssh target.
6
+ */
7
+
8
+ import { AGENT_OPTIONS } from "./agent-options";
9
+ import { probeAgents } from "./agents/probe";
10
+ import { gitCheck } from "./git";
11
+ import { listHosts } from "./hosts";
12
+ import { projectMachines } from "./hosts/project-machines";
13
+ import { getModelOptions, getDefaultModelForAgent } from "./model-catalog";
14
+ import { effectiveModels } from "./models/config";
15
+ import { modelChoices } from "./models/choices";
16
+ import { resolveRole } from "./models/resolve";
17
+ import { getProject } from "./projects";
18
+ import { expandHome } from "./tasks/session";
19
+ import { taskRouting } from "./tasks";
20
+ import type { StartOptions } from "./tasks/loop-types";
21
+
22
+ const BRANCHES_MAX = 50;
23
+
24
+ export class UnknownProjectError extends Error {}
25
+
26
+ export async function startOptions(
27
+ projectId: string | null
28
+ ): Promise<StartOptions> {
29
+ const project = projectId ? getProject(projectId) : undefined;
30
+ if (projectId && (!project || project.is_uncategorized))
31
+ throw new UnknownProjectError("Project not found");
32
+ const here = !project || (project.host_id || "local") === "local";
33
+ const models = effectiveModels();
34
+
35
+ let planDefault = false;
36
+ let planError: string | null = null;
37
+ const roles = { plan: "", implement: "" };
38
+ try {
39
+ const opts = { workspaceId: project?.workspace_id ?? null };
40
+ roles.plan = resolveRole("plan", opts, models).ref;
41
+ roles.implement = resolveRole("implement", opts, models).ref;
42
+ planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
43
+ } catch (error) {
44
+ planError = error instanceof Error ? error.message : String(error);
45
+ }
46
+
47
+ const [probes, git, machines] = await Promise.all([
48
+ here ? probeAgents() : Promise.resolve(null),
49
+ project && here
50
+ ? gitCheck(expandHome(project.working_directory))
51
+ : Promise.resolve(null),
52
+ project
53
+ ? projectMachines(project)
54
+ : Promise.resolve<Record<string, string | null>>({}),
55
+ ]);
56
+
57
+ return {
58
+ project: project
59
+ ? {
60
+ id: project.id,
61
+ name: project.name,
62
+ agentType: project.agent_type,
63
+ defaultModel: project.default_model,
64
+ hostId: project.host_id || "local",
65
+ }
66
+ : null,
67
+ agents: AGENT_OPTIONS.map((a) => ({
68
+ value: a.value,
69
+ label: a.label,
70
+ installed: probes?.[a.value]?.installed ?? null,
71
+ auth: probes?.[a.value]?.auth ?? null,
72
+ hint: probes?.[a.value]?.hint ?? null,
73
+ models: getModelOptions(a.value),
74
+ defaultModel: getDefaultModelForAgent(a.value),
75
+ })),
76
+ taskModels: modelChoices(models),
77
+ roles,
78
+ planDefault,
79
+ planError,
80
+ hosts: listHosts().map((h) => ({
81
+ id: h.id,
82
+ name: h.name,
83
+ linked: h.id === "local" || Boolean(h.linked),
84
+ unavailable: machines[h.id] ?? null,
85
+ })),
86
+ git: git && {
87
+ isGitRepo: git.isGitRepo,
88
+ defaultBranch: git.defaultBranch,
89
+ currentBranch: git.currentBranch,
90
+ branches: git.branches.slice(0, BRANCHES_MAX),
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * A task's PR checks for the phone: each check's name, state, link and
3
+ * duration, the tail of a failing Actions job's log (the same one AgentOS
4
+ * sends a task when its CI fails), and the review gate's verdict on the head.
5
+ * Log and verdict text is untrusted: displayed, never run.
6
+ */
7
+
8
+ import { getCheck } from "../orchestrator/checks";
9
+ import { restackedOf } from "../orchestrator/signoff";
10
+ import { run, settledRollup } from "./gh";
11
+ import { failedLog, repoOfPR, type FailedLog } from "./pr-activity";
12
+ import { prFor, projectPathFor, storedPR } from "./session";
13
+ import type { RollupEntry } from "./state";
14
+ import {
15
+ CHECK_LOGS_MAX,
16
+ CHECK_LOG_MAX_BYTES,
17
+ parseReviewDetail,
18
+ type CheckState,
19
+ type TaskCheck,
20
+ type TaskChecks,
21
+ type TaskReviewVerdict,
22
+ } from "./loop-types";
23
+ import type { Session } from "../db";
24
+
25
+ const outcome = (c: RollupEntry) =>
26
+ (c.conclusion || c.state || c.status || "").toUpperCase();
27
+
28
+ export function checkState(c: RollupEntry): CheckState {
29
+ switch (outcome(c)) {
30
+ case "SUCCESS":
31
+ return "pass";
32
+ case "FAILURE":
33
+ case "ERROR":
34
+ case "TIMED_OUT":
35
+ case "ACTION_REQUIRED":
36
+ return "fail";
37
+ case "SKIPPED":
38
+ return "skipped";
39
+ case "CANCELLED":
40
+ return "cancelled";
41
+ case "NEUTRAL":
42
+ case "STALE":
43
+ return "neutral";
44
+ default:
45
+ return "pending";
46
+ }
47
+ }
48
+
49
+ // gh gives a run not finished yet a zero completedAt (0001-01-01).
50
+ const time = (t?: string | null) => {
51
+ const ms = Date.parse(t || "");
52
+ return ms > 0 ? ms : null;
53
+ };
54
+ const iso = (t?: string | null) => {
55
+ const ms = time(t);
56
+ return ms === null ? null : new Date(ms).toISOString();
57
+ };
58
+
59
+ // A status context can carry any URL; only a web one is a link.
60
+ const httpsUrl = (u?: string | null) =>
61
+ u && /^https:\/\/[^\s]+$/.test(u) ? u : null;
62
+
63
+ export function toTaskChecks(rollup: RollupEntry[]): TaskCheck[] {
64
+ return rollup.map((c) => {
65
+ const start = time(c.startedAt);
66
+ const end = time(c.completedAt);
67
+ return {
68
+ name: c.name ?? c.context ?? "(unnamed)",
69
+ workflow: c.workflowName || null,
70
+ state: checkState(c),
71
+ conclusion: (c.conclusion || c.state || null)?.toLowerCase() ?? null,
72
+ url: httpsUrl(c.detailsUrl),
73
+ startedAt: iso(c.startedAt),
74
+ completedAt: iso(c.completedAt),
75
+ durationSec:
76
+ start !== null && end !== null && end >= start
77
+ ? Math.round((end - start) / 1000)
78
+ : null,
79
+ };
80
+ });
81
+ }
82
+
83
+ // A finished job's log never changes.
84
+ const logCache = new Map<string, FailedLog | null>();
85
+ const LOG_CACHE_MAX = 200;
86
+ export const resetLogCache = () => logCache.clear();
87
+
88
+ export interface ChecksDeps {
89
+ failedLog: typeof failedLog;
90
+ }
91
+
92
+ export async function taskChecks(
93
+ session: Session,
94
+ opts: { logs?: boolean; deps?: ChecksDeps } = {}
95
+ ): Promise<TaskChecks> {
96
+ const deps = opts.deps ?? { failedLog };
97
+ const dir = projectPathFor(session);
98
+ const live = session.task_status === "running";
99
+ const pr = dir
100
+ ? live
101
+ ? await prFor(session).catch(() => null)
102
+ : storedPR(session)
103
+ : null;
104
+ if (!pr || !dir) return { pr: null, checks: [], review: null };
105
+ const head = pr.head ?? null;
106
+
107
+ let checks: TaskCheck[] = [];
108
+ let rollup: RollupEntry[] = [];
109
+ try {
110
+ const out = JSON.parse(
111
+ await run(
112
+ "gh",
113
+ [
114
+ "pr",
115
+ "view",
116
+ String(pr.number),
117
+ "--json",
118
+ "headRefOid,statusCheckRollup",
119
+ ],
120
+ dir,
121
+ 20000
122
+ )
123
+ ) as { headRefOid?: string; statusCheckRollup?: RollupEntry[] | null };
124
+ rollup = out.statusCheckRollup ?? [];
125
+ if (pr.state === "OPEN")
126
+ rollup = await settledRollup(dir, pr.url, out.headRefOid, rollup);
127
+ checks = toTaskChecks(rollup);
128
+ } catch (error) {
129
+ console.error(`Reading ${session.name}'s checks:`, error);
130
+ }
131
+
132
+ const repo = repoOfPR(pr.url);
133
+ if (opts.logs !== false && repo) {
134
+ const failing = checks
135
+ .map((c, i) => ({ c, url: c.state === "fail" ? c.url : null, i }))
136
+ .filter((x) => x.url)
137
+ .slice(0, CHECK_LOGS_MAX);
138
+ await Promise.all(
139
+ failing.map(async ({ c, url }) => {
140
+ let log = logCache.get(url!);
141
+ if (log === undefined) {
142
+ log = await deps.failedLog(dir, repo, url).catch((error) => {
143
+ console.error(`Reading ${session.name}'s CI log:`, error);
144
+ return null;
145
+ });
146
+ if (log) {
147
+ if (logCache.size >= LOG_CACHE_MAX) logCache.clear();
148
+ logCache.set(url!, log);
149
+ }
150
+ }
151
+ if (log) {
152
+ const tail = log.tail.slice(-CHECK_LOG_MAX_BYTES);
153
+ c.log = {
154
+ step: log.step,
155
+ tail,
156
+ truncated: tail.length < log.tail.length,
157
+ };
158
+ }
159
+ })
160
+ );
161
+ }
162
+
163
+ return {
164
+ pr: { number: pr.number, url: pr.url, state: pr.state, head },
165
+ checks,
166
+ review: head ? reviewVerdict(session, head) : null,
167
+ };
168
+ }
169
+
170
+ // The review covering the head: its own, or the one of the head the task
171
+ // pushed while the head is exactly the one AgentOS restacked it to.
172
+ export function reviewVerdict(
173
+ session: Session,
174
+ head: string
175
+ ): TaskReviewVerdict | null {
176
+ const restacked = restackedOf(session);
177
+ const row =
178
+ getCheck(session.id, head, "review") ??
179
+ (restacked?.to === head && restacked.from
180
+ ? getCheck(session.id, restacked.from, "review")
181
+ : null);
182
+ if (!row) return null;
183
+ const { summary, findings, truncated } = parseReviewDetail(row.detail);
184
+ return {
185
+ sha: row.sha,
186
+ status: row.status,
187
+ summary,
188
+ findings,
189
+ at: row.created_at,
190
+ truncated,
191
+ };
192
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * A task's whole change, base branch to head (merge-base), for the phone to
3
+ * review: a file list with status and line counts, then one file's patch.
4
+ * Read from the task's worktree when it's there, else from its PR on GitHub.
5
+ * Sizes are capped and say so; a file is only ever one the change lists.
6
+ */
7
+
8
+ import { git, isGitError, resolveRef } from "../git-exec";
9
+ import { getDefaultBranch, isGitRepo, isBranchName } from "../git";
10
+ import { existingRef } from "../orchestrator/repo";
11
+ import { run } from "./gh";
12
+ import { repoOfPR } from "./pr-activity";
13
+ import { localWorktree, projectPathFor } from "./session";
14
+ import { LoopError } from "./loop-error";
15
+ import {
16
+ DIFF_MAX_FILES,
17
+ DIFF_PATCH_MAX_BYTES,
18
+ type TaskDiffFile,
19
+ type TaskDiffList,
20
+ type TaskDiffPatch,
21
+ } from "./loop-types";
22
+ import type { Session } from "../db";
23
+
24
+ export interface DiffCaps {
25
+ files: number;
26
+ patchBytes: number;
27
+ }
28
+ const CAPS: DiffCaps = {
29
+ files: DIFF_MAX_FILES,
30
+ patchBytes: DIFF_PATCH_MAX_BYTES,
31
+ };
32
+
33
+ const MAX_PATH_BYTES = 1024;
34
+
35
+ /** A path the change could list: relative, inside the repo, no tricks. */
36
+ export function validDiffPath(file: unknown): file is string {
37
+ if (typeof file !== "string" || !file) return false;
38
+ if (Buffer.byteLength(file) > MAX_PATH_BYTES) return false;
39
+ if (file.includes("\0") || file.includes("\\")) return false;
40
+ if (file.startsWith("/") || file.startsWith("-")) return false;
41
+ return !file.split("/").some((part) => part === ".." || part === "");
42
+ }
43
+
44
+ const STATUS_OF_GITHUB: Record<string, string> = {
45
+ added: "A",
46
+ removed: "D",
47
+ modified: "M",
48
+ changed: "M",
49
+ renamed: "R",
50
+ copied: "C",
51
+ };
52
+
53
+ interface Source {
54
+ kind: "worktree" | "pr";
55
+ base: string;
56
+ head: string;
57
+ list: () => Promise<TaskDiffFile[]>;
58
+ patch: (file: string) => Promise<{ patch: string | null; tooLarge: boolean }>;
59
+ }
60
+
61
+ // `git diff --numstat -z` and `--name-status -z`, joined by path.
62
+ function parseGit(numstat: string, nameStatus: string): TaskDiffFile[] {
63
+ const status = new Map<string, string>();
64
+ const parts = nameStatus.split("\0");
65
+ for (let i = 0; i + 1 < parts.length; i += 2)
66
+ status.set(parts[i + 1], parts[i][0] || "M");
67
+ const files: TaskDiffFile[] = [];
68
+ for (const row of numstat.split("\0")) {
69
+ const m = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(row);
70
+ if (!m) continue;
71
+ const binary = m[1] === "-";
72
+ files.push({
73
+ path: m[3],
74
+ status: status.get(m[3]) ?? "M",
75
+ additions: binary ? null : Number(m[1]),
76
+ deletions: binary ? null : Number(m[2]),
77
+ binary,
78
+ });
79
+ }
80
+ return files;
81
+ }
82
+
83
+ async function worktreeSource(
84
+ session: Session,
85
+ dir: string,
86
+ caps: DiffCaps
87
+ ): Promise<Source> {
88
+ if (!(await isGitRepo(dir))) throw new LoopError("No repository", 404);
89
+ const recorded = session.base_branch || (await getDefaultBranch(dir));
90
+ if (!isBranchName(recorded)) throw new LoopError("Unknown base branch", 404);
91
+ const base = await existingRef(dir, recorded);
92
+ if (!base) throw new LoopError("The base branch isn't here", 404);
93
+ const head = (await resolveRef(dir, "HEAD")) ?? "";
94
+ const range = `${base}...HEAD`;
95
+ const flags = ["--no-renames", "--no-ext-diff", "--no-textconv"];
96
+ return {
97
+ kind: "worktree",
98
+ base: recorded,
99
+ head,
100
+ list: async () => {
101
+ const [numstat, names] = await Promise.all([
102
+ git(dir, ["diff", "--numstat", "-z", ...flags, range, "--"]),
103
+ git(dir, ["diff", "--name-status", "-z", ...flags, range, "--"]),
104
+ ]);
105
+ return parseGit(numstat, names);
106
+ },
107
+ patch: async (file) => {
108
+ try {
109
+ const out = await git(
110
+ dir,
111
+ ["diff", "--no-color", ...flags, range, "--", file],
112
+ { literalPathspecs: true, maxBuffer: caps.patchBytes * 4 }
113
+ );
114
+ return { patch: out, tooLarge: false };
115
+ } catch (error) {
116
+ if (
117
+ isGitError(error) &&
118
+ error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
119
+ )
120
+ return { patch: null, tooLarge: true };
121
+ throw error;
122
+ }
123
+ },
124
+ };
125
+ }
126
+
127
+ interface GithubFile {
128
+ filename?: string;
129
+ status?: string;
130
+ additions?: number;
131
+ deletions?: number;
132
+ patch?: string;
133
+ }
134
+
135
+ async function prSource(session: Session): Promise<Source> {
136
+ const repo = session.pr_url ? repoOfPR(session.pr_url) : null;
137
+ const dir = projectPathFor(session);
138
+ if (!repo || !session.pr_number || !dir)
139
+ throw new LoopError("The task has no worktree and no pull request", 404);
140
+ const slug = `${repo.owner}/${repo.name}`;
141
+ const meta = JSON.parse(
142
+ await run(
143
+ "gh",
144
+ [
145
+ "pr",
146
+ "view",
147
+ String(session.pr_number),
148
+ "--repo",
149
+ slug,
150
+ "--json",
151
+ "baseRefName,headRefOid",
152
+ ],
153
+ dir,
154
+ 15000
155
+ )
156
+ ) as { baseRefName?: string; headRefOid?: string };
157
+ const pages = async (): Promise<GithubFile[]> => {
158
+ const all: GithubFile[] = [];
159
+ for (let page = 1; page <= 3; page++) {
160
+ const rows = JSON.parse(
161
+ await run(
162
+ "gh",
163
+ [
164
+ "api",
165
+ `repos/${slug}/pulls/${session.pr_number}/files?per_page=100&page=${page}`,
166
+ ],
167
+ dir,
168
+ 30000
169
+ )
170
+ ) as GithubFile[];
171
+ all.push(...rows);
172
+ if (rows.length < 100) break;
173
+ }
174
+ return all;
175
+ };
176
+ let cached: GithubFile[] | null = null;
177
+ const rows = async () => (cached ??= await pages());
178
+ return {
179
+ kind: "pr",
180
+ base: meta.baseRefName ?? "",
181
+ head: meta.headRefOid ?? "",
182
+ list: async () =>
183
+ (await rows())
184
+ .filter((f) => typeof f.filename === "string")
185
+ .map((f) => ({
186
+ path: f.filename!,
187
+ status: STATUS_OF_GITHUB[f.status ?? ""] ?? "M",
188
+ additions: f.additions ?? 0,
189
+ deletions: f.deletions ?? 0,
190
+ binary: false,
191
+ })),
192
+ patch: async (file) => {
193
+ const hit = (await rows()).find((f) => f.filename === file);
194
+ return typeof hit?.patch === "string"
195
+ ? { patch: hit.patch, tooLarge: false }
196
+ : { patch: null, tooLarge: true };
197
+ },
198
+ };
199
+ }
200
+
201
+ async function sourceFor(session: Session, caps: DiffCaps): Promise<Source> {
202
+ const dir = localWorktree(session);
203
+ if (dir) return worktreeSource(session, dir, caps);
204
+ return prSource(session);
205
+ }
206
+
207
+ // Cut at a line break at or before `max` bytes.
208
+ export function capPatch(
209
+ patch: string,
210
+ max: number
211
+ ): { patch: string; truncated: boolean } {
212
+ if (Buffer.byteLength(patch) <= max) return { patch, truncated: false };
213
+ const cut = Buffer.from(patch).subarray(0, max).toString("utf8");
214
+ const nl = cut.lastIndexOf("\n");
215
+ return { patch: nl > 0 ? cut.slice(0, nl) : cut, truncated: true };
216
+ }
217
+
218
+ /** The file list, or with `file` that one file's patch. */
219
+ export async function taskDiff(
220
+ session: Session,
221
+ file?: string | null,
222
+ caps: DiffCaps = CAPS
223
+ ): Promise<TaskDiffList | TaskDiffPatch> {
224
+ if (file != null && !validDiffPath(file))
225
+ throw new LoopError("Not a path in the change", 400);
226
+ const source = await sourceFor(session, caps);
227
+ const all = await source.list();
228
+ if (file != null) {
229
+ if (!all.some((f) => f.path === file))
230
+ throw new LoopError("That file isn't in the change", 404);
231
+ const { patch, tooLarge } = await source.patch(file);
232
+ if (patch === null)
233
+ return {
234
+ source: source.kind,
235
+ path: file,
236
+ patch: null,
237
+ truncated: true,
238
+ tooLarge: true,
239
+ };
240
+ const capped = capPatch(patch, caps.patchBytes);
241
+ return {
242
+ source: source.kind,
243
+ path: file,
244
+ patch: capped.patch,
245
+ truncated: capped.truncated,
246
+ tooLarge,
247
+ };
248
+ }
249
+ return {
250
+ source: source.kind,
251
+ base: source.base,
252
+ head: source.head,
253
+ files: all.slice(0, caps.files),
254
+ totalFiles: all.length,
255
+ additions: all.reduce((n, f) => n + (f.additions ?? 0), 0),
256
+ deletions: all.reduce((n, f) => n + (f.deletions ?? 0), 0),
257
+ truncated: all.length > caps.files,
258
+ };
259
+ }
package/lib/tasks/gh.ts CHANGED
@@ -133,7 +133,7 @@ const RUN_CACHE_MAX = 2000;
133
133
  // The rollup with stale reruns dropped. Only a check that ran more than once
134
134
  // costs an API call, one per workflow run attempt. A run outside the PR's
135
135
  // repo, or one the API can't describe, leaves its check unsettled.
136
- async function settledRollup(
136
+ export async function settledRollup(
137
137
  repoDir: string,
138
138
  prUrl: string,
139
139
  head: string | undefined,
@@ -47,6 +47,8 @@ import { expandHome, prFor, storedPR, taskSessions } from "./session";
47
47
  import { InProgressError } from "./move-bundle";
48
48
  import { nameFor } from "../session-titles";
49
49
  import { taskSetupOf, type TaskSetup } from "./setup";
50
+ import { taskPlan } from "./plan-view";
51
+ import type { TaskPlan } from "./loop-types";
50
52
  import { finishTaskStart } from "./start";
51
53
  import { isRemoteHost } from "../hosts";
52
54
  import { DEFAULT_START_VIEW, type StartView } from "../sessions/launch";
@@ -98,7 +100,8 @@ export interface TaskView {
98
100
  model?: string;
99
101
  planning?: boolean;
100
102
  // What its starter decided about a plan step, and why; null if unstated.
101
- plan?: { planned: boolean; reason: string | null } | null;
103
+ // Its plan: ready to read or approve, with a title and capped text.
104
+ plan?: TaskPlan | null;
102
105
  // A quick change: the orchestrator session that makes it (no agent).
103
106
  quickOf?: string | null;
104
107
  // What it has cost so far in weighted tokens (lib/usage/weights).
@@ -389,13 +392,7 @@ export async function taskView(session: Session): Promise<TaskView> {
389
392
  armed: !!session.merge_armed_at,
390
393
  model: sessionModelRef(session),
391
394
  planning: session.plan_phase === "planning",
392
- plan:
393
- session.plan_decided == null
394
- ? null
395
- : {
396
- planned: !!session.plan_decided,
397
- reason: session.plan_reason ?? null,
398
- },
395
+ plan: taskPlan(session),
399
396
  quickOf: session.quick_of ?? null,
400
397
  };
401
398
  }
@@ -413,7 +410,7 @@ export function movedTasks(): { id: string; movedTo: string | null }[] {
413
410
 
414
411
  // Archived tasks aren't listed.
415
412
  export async function listTasks(): Promise<TaskView[]> {
416
- const sessions = taskSessions().filter((s) => !s.archived_at);
413
+ const sessions = taskSessions(false);
417
414
  const [local, remote] = await Promise.all([
418
415
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
419
416
  remoteTaskViews(sessions.filter(isMirror)),
@@ -0,0 +1,21 @@
1
+ // A refusal the loop routes turn into a status code.
2
+ export class LoopError extends Error {
3
+ constructor(
4
+ message: string,
5
+ readonly status: 400 | 404 | 409
6
+ ) {
7
+ super(message);
8
+ this.name = "LoopError";
9
+ }
10
+ }
11
+
12
+ /** The task a route names: unknown, not a task, or another machine's. */
13
+ export function localTask<T extends { task_status?: string | null }>(
14
+ session: T | undefined,
15
+ mirror: (s: T) => boolean
16
+ ): T {
17
+ if (!session?.task_status) throw new LoopError("Task not found", 404);
18
+ if (mirror(session))
19
+ throw new LoopError("That task runs on another machine", 409);
20
+ return session;
21
+ }