openrod 0.1.2 → 0.1.3

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.
Files changed (60) hide show
  1. package/README.md +1 -2
  2. package/THIRD_PARTY_NOTICES.md +84 -1
  3. package/dist/assets/index-DBqg9X9x.js +553 -0
  4. package/dist/assets/index-HZ0q_FBv.css +1 -0
  5. package/dist/assets/index.esm-BqjC2fhp.js +376 -0
  6. package/dist/assets/index.esm-Cj6nXjOx.js +16 -0
  7. package/dist/assets/index.esm-Z1y-oluO.js +1666 -0
  8. package/dist/assets/terminal-view-D-SQcdM5.js +40 -0
  9. package/dist/index.html +3 -3
  10. package/package.json +8 -1
  11. package/server/announcements.js +22 -0
  12. package/server/api.js +54 -11
  13. package/server/capabilities.js +13 -0
  14. package/server/cli.js +18 -2
  15. package/server/cloud-deployment.js +29 -0
  16. package/server/cloud-proxy.js +19 -9
  17. package/server/cloud-runtime.js +72 -0
  18. package/server/cloud-ssh-proxy.cjs +22 -4
  19. package/server/cloud-transfer.js +1 -1
  20. package/server/compute.js +18 -7
  21. package/server/image-templates.js +17 -0
  22. package/server/ingress.js +3 -0
  23. package/server/local-cloud-native.js +7 -1
  24. package/server/local-cloud.js +3 -3
  25. package/server/machines.js +33 -18
  26. package/server/policy-template-store.js +1 -0
  27. package/server/remote-gateway.js +26 -6
  28. package/server/remote-http.js +1 -1
  29. package/server/resource-import-portable.js +155 -0
  30. package/server/resource-imports.js +412 -0
  31. package/server/sandbox-identity.js +1 -1
  32. package/server/security.js +30 -14
  33. package/server/setup-discovery.js +2 -1
  34. package/server/setup-packages.js +138 -59
  35. package/server/setup-preparation.js +4 -3
  36. package/server/start.js +29 -7
  37. package/server/worker-auth.js +2 -2
  38. package/shared/agent-access.js +26 -4
  39. package/shared/analytics-release.json +5 -0
  40. package/src/lib/activity-inventory.js +1 -1
  41. package/src/lib/analytics.js +334 -0
  42. package/src/lib/api.js +28 -11
  43. package/src/lib/cloud-sign-in-error.js +13 -0
  44. package/src/lib/cloud-transfer.js +1 -1
  45. package/src/lib/combined-activity.js +95 -0
  46. package/src/lib/connection-host.js +6 -0
  47. package/src/lib/egress-inventory.js +63 -0
  48. package/src/lib/local-cloud.js +16 -3
  49. package/src/lib/location-choice.js +7 -0
  50. package/src/lib/location-data-reader.js +25 -0
  51. package/src/lib/locations.js +18 -8
  52. package/src/lib/placement.js +1 -1
  53. package/src/lib/resource-imports.js +63 -0
  54. package/src/lib/resource-sources.js +105 -0
  55. package/src/lib/sandbox-creations.js +14 -9
  56. package/src/lib/sandboxes.js +5 -1
  57. package/src/lib/working-location.js +10 -0
  58. package/dist/assets/index-89TNohi_.css +0 -1
  59. package/dist/assets/index-DoKK2G9s.js +0 -545
  60. package/dist/assets/terminal-view-DzqQnmh2.js +0 -40
@@ -19,15 +19,15 @@ export function localConnectFromHash(hash) {
19
19
  export function isLocalConnectedMessage(event, popup, nonce, cloud = CLOUD_ORIGIN) {
20
20
  return Boolean(cloud && popup && event.source === popup && event.origin === cloud && event.data?.type === 'openrod-local-connected' && event.data.nonce === nonce && typeof event.data.code === 'string' && /^[a-f0-9]{64}\.[a-f0-9]{64}$/.test(event.data.code))
21
21
  }
22
- export async function localCloudRequest(path, body, {signal} = {}) {
22
+ export async function localCloudRequest(path, body, {signal, owner = currentCloudOwner()} = {}) {
23
23
  const response = await fetch(`/api/local-cloud/${path}`, {
24
24
  signal,
25
25
  method: body === undefined ? 'GET' : 'POST',
26
- headers: { ...(currentCloudOwner() ? { 'x-openrod-local-owner': currentCloudOwner() } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json', 'x-openshell-console': '1' }) },
26
+ headers: { ...(owner ? { 'x-openrod-local-owner': owner } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json', 'x-openshell-console': '1' }) },
27
27
  body: body === undefined ? undefined : JSON.stringify(body),
28
28
  })
29
29
  const value = await response.json().catch(() => ({}))
30
- if (value.code === 'CLOUD_OWNER_CHANGED') window.dispatchEvent(new Event('openrod-session-expired'))
30
+ if (value.code === 'CLOUD_OWNER_CHANGED' || (response.status === 401 && ['machine', 'inventory'].includes(path))) window.dispatchEvent(new Event('openrod-session-expired'))
31
31
  if (!response.ok) throw Object.assign(new Error(value.error ?? 'Cloud connection unavailable'), {status:response.status})
32
32
  return value
33
33
  }
@@ -52,6 +52,19 @@ export async function waitForCloudReady(statusRequest, { signal, onProgress = ()
52
52
  }
53
53
  throw new Error('Your private machine is still preparing. Check its status before trying again.')
54
54
  }
55
+ // Preparation is an explicit mutation. Every request remains bound to the
56
+ // account selected before it began, including after an account switch.
57
+ export async function prepareCloudMachine({ owner, signal, onProgress, request = localCloudRequest, wait, attempts } = {}) {
58
+ signal?.throwIfAborted()
59
+ const started = await request('machine', {}, { signal, owner })
60
+ let first = true
61
+ return waitForCloudReady(async () => {
62
+ const value = first ? started : await request('machine', undefined, { signal, owner })
63
+ first = false
64
+ if (value.owner !== owner) throw new Error('Your cloud account changed. Choose the destination again.')
65
+ return value
66
+ }, { signal, onProgress, wait, attempts })
67
+ }
55
68
  export async function copyLocalSandbox(local, cloud, name, destinationGroups) {
56
69
  const exported = await local.cloudExport(name)
57
70
  const result = await cloud.importCloud(destinationGroups ? { ...exported.bundle, destinationGroups } : exported.bundle)
@@ -0,0 +1,7 @@
1
+ // With only one reachable place to put it, a new resource goes straight there.
2
+ // Any other location (a Remote, connected or saved, or Cloud) keeps the choice,
3
+ // as does an OpenRod Cloud this console can connect but has not yet.
4
+ export function directLocation(locations, { offerCloud = false } = {}) {
5
+ const [only] = locations
6
+ return locations.length === 1 && !only.remote && only.connected && !offerCloud ? only : null
7
+ }
@@ -0,0 +1,25 @@
1
+ // Read each source independently. A dead SSH host never prevents Local data
2
+ // from loading, and cached readings keep their original owner.
3
+ export async function readLocationData(locations, methods, apiFor, previous = []) {
4
+ const cached = new Map(previous.map(source => [source.location.id ?? source.location.context, source.data]))
5
+ return Promise.all(locations.map(async location => {
6
+ const before = cached.get(location.id ?? location.context)
7
+ if (!location.connected) return { location, data: before ?? null, error: null }
8
+ const results = await Promise.allSettled(methods.map(method => apiFor(location)[method]()))
9
+ const data = {}, errors = []
10
+ results.forEach((result, i) => {
11
+ if (result.status === 'fulfilled') data[methods[i]] = result.value
12
+ else { if (before?.[methods[i]] !== undefined) data[methods[i]] = before[methods[i]]; errors.push(result.reason.message) }
13
+ })
14
+ return { location: errors.length && errors.length === methods.length ? { ...location, connected: false } : location, data: Object.keys(data).length ? data : null, error: [...new Set(errors)].join('; ') || null }
15
+ }))
16
+ }
17
+
18
+ // Sources a combined page could not read: failed reads, and offline sources
19
+ // with nothing cached. Unavailable means no source has a full reading to show.
20
+ export function unreadSources(sources, methods) {
21
+ const complete = source => methods.every(method => source.data?.[method] !== undefined)
22
+ const failed = sources.filter(source => source.error)
23
+ const offline = sources.filter(source => !source.error && !complete(source) && source.location.connected === false)
24
+ return { failed, offline, any: failed.length + offline.length > 0, unavailable: sources.length > 0 && sources.every(source => !complete(source) && (source.error || source.location.connected === false)) }
25
+ }
@@ -1,16 +1,26 @@
1
+ import { analytics, classifyAnalyticsError } from './analytics.js'
2
+
1
3
  export const locationLabel = (location) => location?.label ?? (location?.target === 'cloud' ? 'Cloud' : location?.remote ? `SSH · ${location.host ?? location.gateway}` : 'Local')
2
4
 
3
5
  // Gateway/workspace identity is part of every resource key: names and even ids
4
6
  // can coincide on independent gateways.
5
7
  export const resourceKey = (record) => JSON.stringify([[record.location?.target ?? 'local', record.location?.context ?? ''], record.id ?? record.name])
6
8
 
7
- // Selecting a local gateway is a job; reload only after it has switched the context.
9
+ const connectionAttempts = new WeakMap()
10
+
11
+ // Selecting a local gateway is a job; continue only after the context switches.
8
12
  export async function connectLocalGateway(api, name, interval = 1000) {
9
- let job = await api.connect({ localGateway: name })
10
- while (job.status === 'working') {
11
- await new Promise((resolve) => setTimeout(resolve, interval))
12
- job = await api.connectionJob(job.id)
13
- }
14
- if (job.status !== 'ready') throw new Error(job.error || `Couldn’t connect to ${name}.`)
15
- return job
13
+ const previous = connectionAttempts.get(api)
14
+ const tracked = analytics.startFlow('gateway_connection', { location_type: 'local' }, previous?.outcome === 'failed' ? previous : null)
15
+ connectionAttempts.set(api, tracked)
16
+ try {
17
+ let job = await api.connect({ localGateway: name })
18
+ while (job.status === 'working') {
19
+ await new Promise((resolve) => setTimeout(resolve, interval))
20
+ job = await api.connectionJob(job.id)
21
+ }
22
+ if (job.status !== 'ready') throw new Error(job.error || `Couldn’t connect to ${name}.`)
23
+ analytics.finishFlow(tracked, 'connected')
24
+ return job
25
+ } catch (error) { analytics.finishFlow(tracked, 'failed', classifyAnalyticsError(error)); throw error }
16
26
  }
@@ -6,4 +6,4 @@ export const PLACEMENTS = {
6
6
  cloud: { id: 'cloud', label: 'Cloud', hint: 'Runs on a cloud machine' },
7
7
  }
8
8
 
9
- export const placementOf = (location) => PLACEMENTS[location?.placement]?.id ?? (location?.remote ? 'remote' : 'local')
9
+ export const placementOf = (location) => location?.cloud || location?.target === 'cloud' ? 'cloud' : PLACEMENTS[location?.placement]?.id ?? (location?.remote ? 'remote' : 'local')
@@ -0,0 +1,63 @@
1
+ export const IMPORT_TYPES = [
2
+ { id: 'groups', label: 'Groups', description: 'Definitions and policy relationships' },
3
+ { id: 'network', label: 'Network', description: 'Egress rules with destination targets' },
4
+ { id: 'setups', label: 'MCPs & Skills', description: 'Saved setups and skill files' },
5
+ { id: 'templates', label: 'Templates', description: 'Portable image recipes' },
6
+ { id: 'activity', label: 'Activity history', description: 'Historical events, in batches of up to 500', optional: true },
7
+ ]
8
+ export const DEFAULT_IMPORT_TYPES = IMPORT_TYPES.filter(type => !type.optional).map(type => type.id)
9
+ export const IMPORT_PAGE_TYPE = { groups: 'groups', egress: 'network', ingress: 'network', setups: 'setups', templates: 'templates', activity: 'activity' }
10
+ export const importRunning = job => ['running', 'cancelling'].includes(job?.status)
11
+ export const importFinished = job => job && !['planned', 'running', 'cancelling'].includes(job.status)
12
+ export function importSelection(resources, selected) {
13
+ const index = new Map(resources.map(item => [item.key, item]))
14
+ const result = new Set()
15
+ function include(key) {
16
+ if (result.has(key)) return
17
+ const item = index.get(key)
18
+ if (!item) throw new Error(`Missing import dependency: ${key}`)
19
+ result.add(key)
20
+ for (const dependency of item.dependencies ?? []) include(dependency)
21
+ }
22
+ selected.forEach(include)
23
+ // Organization restrictions travel with their groups. The backend records
24
+ // these reverse dependencies so a group cannot lose source blocks during a
25
+ // partial selection, even if the Network category was not selected.
26
+ for (let previous = -1; previous !== result.size;) {
27
+ previous = result.size
28
+ for (const item of resources) if (item.requiredForGroups?.some(id => result.has(`groups:${id}`))) include(item.key)
29
+ }
30
+ return [...result]
31
+ }
32
+ export const importSourceId = location => location?.id ?? JSON.stringify([location?.target, location?.context])
33
+ export function activityImportQuery({ from = '', to = '', offset = 0, snapshot } = {}) {
34
+ const query = { offset }
35
+ for (const [key, value] of Object.entries({ from, to })) {
36
+ if (!value) continue
37
+ const date = new Date(value)
38
+ if (!Number.isFinite(date.getTime())) throw new Error('Choose a valid activity date range.')
39
+ query[key] = date.toISOString()
40
+ }
41
+ if (query.from && query.to && query.from > query.to) throw new Error('Activity start must be before its end.')
42
+ if (snapshot != null) query.snapshot = snapshot
43
+ return query
44
+ }
45
+ export function importPercent(job) {
46
+ if (!job?.items?.length) return 0
47
+ const done = job.items.filter(item => ['created', 'completed', 'reused', 'skipped', 'blocked', 'failed', 'cancelled'].includes(item.status)).length
48
+ return Math.round(done / job.items.length * 100)
49
+ }
50
+
51
+ // A saved setup is the import unit. Mixed setups stay together so their
52
+ // dependencies and selection keys remain intact.
53
+ export function groupImportResources(resources) {
54
+ const sections = [...IMPORT_TYPES, { id: 'policyTemplates', label: 'Base policies' }].map(type => ({ ...type, items: resources.filter(item => item.type === type.id) }))
55
+ const other = resources.filter(item => !sections.some(section => section.id === item.type))
56
+ if (other.length) sections.push({ id: 'other', label: 'Other resources', items: other })
57
+ return sections.filter(section => section.items.length).map(section => {
58
+ if (section.id !== 'setups') return section
59
+ const kinds = item => new Set((item.data?.items ?? []).map(value => value.kind))
60
+ const category = item => { const types = kinds(item); return types.has('mcp') && types.has('skill') ? 'mixed' : types.has('mcp') ? 'mcps' : types.has('skill') ? 'skills' : 'saved' }
61
+ return { ...section, groups: [{ id: 'mcps', label: 'MCPs' }, { id: 'skills', label: 'Skills' }, { id: 'mixed', label: 'MCPs & Skills together' }, { id: 'saved', label: 'Saved setups' }].map(group => ({ ...group, items: section.items.filter(item => category(item) === group.id) })).filter(group => group.items.length) }
62
+ })
63
+ }
@@ -0,0 +1,105 @@
1
+
2
+ export function canonical(value) {
3
+ if (Array.isArray(value)) return value.map(canonical)
4
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])]))
5
+ return value
6
+ }
7
+ const omit = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)))
8
+ const metadata = ['location', 'copies', 'sourceOrg', 'sourceSetups', 'id', 'createdAt', 'updatedAt', 'revision', 'importedAt', 'importSource', 'localSource', 'configurationCount']
9
+ export function resourceConfiguration(type, record) {
10
+ if (type === 'network' && record.sourceOrg) {
11
+ // Group template IDs are source-local references to separate policies, not
12
+ // part of this network rule. Imports assign new IDs to those templates.
13
+ const groupRef = id => { const group=record.sourceOrg.groups.find(group=>group.id===id); return group ? omit(resourceConfiguration('groups',group), ['template']) : {missing:id,source:record.location?.id} }
14
+ const setupRef = id => { const setup=record.sourceSetups?.find(setup=>setup.id===id); return setup ? resourceConfiguration('setups',setup) : {missing:id,source:record.location?.id} }
15
+ const sort = values => values.map(value=>canonical(value)).sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b)))
16
+ const value=omit(record,metadata)
17
+ return {...value,destinations:sort(value.destinations),appliesTo:{...value.appliesTo,groups:sort(value.appliesTo.groups.map(groupRef)),setups:sort((value.appliesTo.setups ?? []).map(setupRef)),sandboxes:sort(value.appliesTo.sandboxes)},...(value.setup?{setup:{...value.setup,id:setupRef(value.setup.id)}}:{})}
18
+ }
19
+ if (type === 'setups') return { name: record.name, items: record.items.map(item => ({ name: item.name, kind: item.kind, contentDigest: item.contentDigest ?? record.revision })).sort((a,b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) }
20
+ if (type === 'templates') return { name: record.name, recipe: record.recipe, managed: record.managed, status: record.status, exists: record.exists }
21
+ return omit(record, metadata)
22
+ }
23
+ export const configurationKey = (type, record) => JSON.stringify(canonical(resourceConfiguration(type, record)))
24
+ export function consolidateResources(type, records) {
25
+ const rows = []
26
+ for (const record of records) {
27
+ // Network rows describe one configuration. Same-named copies split when
28
+ // edited independently and merge again when their configurations match.
29
+ const key = type !== 'network' && record.name
30
+ ? JSON.stringify([type, record.name])
31
+ : configurationKey(type, record)
32
+ const row = rows.find(row => row.key === key && !row.copies.some(copy => copy.location?.id === record.location?.id))
33
+ if (row) row.copies.push(record)
34
+ else rows.push({key, copies:[record]})
35
+ }
36
+ return rows.map(({copies}) => ({...(copies.find(copy => copy.location?.connected !== false) ?? copies[0]), copies, configurationCount: new Set(copies.map(copy => configurationKey(type, copy))).size}))
37
+ }
38
+ export const resourceCopies = record => record?.copies ?? (record ? [record] : [])
39
+ export function matchingSources(record, records) {
40
+ // Offer only peers from the row as it existed when editing began. A separate
41
+ // same-named row is independent, even if this edit makes it identical again.
42
+ const copies = resourceCopies(record)
43
+ return records.filter(candidate => candidate.location?.id !== record.location?.id
44
+ && copies.some(copy => copy.location?.id === candidate.location?.id && (copy.id ?? copy.name) === (candidate.id ?? candidate.name))
45
+ && records.filter(other => other.location?.id === candidate.location?.id && (other.id ?? other.name) === (candidate.id ?? candidate.name)).length === 1)
46
+ }
47
+ function mergeChanged(before, after, destination) {
48
+ if (after && typeof after === 'object' && !Array.isArray(after)) {
49
+ const result={...destination}
50
+ for(const [key,value] of Object.entries(after)) {
51
+ if(JSON.stringify(canonical(before?.[key]))!==JSON.stringify(canonical(value))) result[key]=mergeChanged(before?.[key],value,destination?.[key])
52
+ }
53
+ return result
54
+ }
55
+ return after
56
+ }
57
+ export async function applySourceChange({ type, before, args, method }, target, api) {
58
+ if (!target.location?.connected) throw new Error('Reconnect this source before applying changes.')
59
+ const list = type === 'setups' ? await api.setups() : type === 'templates' ? await api.imageTemplates() : (await api.org())[type === 'network' ? 'policies' : 'groups']
60
+ const current = (Array.isArray(list) ? list : list.templates ?? []).find(item => (item.id ?? item.name) === (target.id ?? target.name))
61
+ if (!current || configurationKey(type, {...current,sourceOrg:undefined,sourceSetups:undefined}) !== configurationKey(type, {...target,sourceOrg:undefined,sourceSetups:undefined})) throw new Error('This source changed. Reopen it before applying the edit.')
62
+ if (method === 'deleteSetupItem') {
63
+ const removed = before.items.find(item => item.id === args[1])
64
+ const matches = current.items.filter(item => item.name === removed?.name && item.kind === removed?.kind && (removed.contentDigest ? item.contentDigest === removed.contentDigest : current.revision === before.revision))
65
+ if (matches.length !== 1) throw new Error('The matching item differs on this source. Edit it separately.')
66
+ return api.deleteSetupItem(current.id, matches[0].id, current.revision)
67
+ }
68
+ const patch = {}
69
+ const old = type === 'templates' ? before.recipe : before
70
+ for (const [key, value] of Object.entries(args[0])) {
71
+ // Template environment values can be credentials. As with imports, they
72
+ // are entered on each source and never copied to another one.
73
+ if (metadata.includes(key) || key === 'isNew' || (type === 'templates' && key === 'environment')) continue
74
+ if (JSON.stringify(canonical(old[key])) !== JSON.stringify(canonical(value))) patch[key] = mergeChanged(old[key],value,(type === 'templates' ? current.recipe : current)[key])
75
+ }
76
+ if (method === 'savePolicy') {
77
+ const org = await api.org()
78
+ const next = {...current, ...patch, id:current.id, isNew:false}
79
+ if (patch.appliesTo) {
80
+ next.appliesTo={...current.appliesTo}
81
+ for(const [key,value] of Object.entries(patch.appliesTo)) {
82
+ if(JSON.stringify(canonical(value))===JSON.stringify(canonical(before.appliesTo[key])))continue
83
+ if(key==='groups') next.appliesTo.groups=value.map(id=>{
84
+ const source=before.sourceOrg?.groups.find(group=>group.id===id)
85
+ const matches=source?org.groups.filter(group=>configurationKey('groups',group)===configurationKey('groups',source)):org.groups.filter(group=>group.id===id)
86
+ if(matches.length!==1)throw new Error('A changed group is missing or ambiguous here. Import or select it separately.')
87
+ return matches[0].id
88
+ })
89
+ else next.appliesTo[key]=value
90
+ }
91
+ }
92
+ if (next.appliesTo.groups.some(id => !org.groups.some(group => group.id === id))) throw new Error('A required group is missing here. Import it first.')
93
+ if (next.appliesTo.setups?.length) {
94
+ const setups = await api.setups()
95
+ if (next.appliesTo.setups.some(id => !setups.some(setup => setup.id === id))) throw new Error('A required setup is missing here. Import it first.')
96
+ }
97
+ return api.savePolicy(next)
98
+ }
99
+ if (method === 'saveGroup') return api.saveGroup({...current, ...patch, id:current.id, isNew:false})
100
+ if (method === 'buildImageTemplate') {
101
+ if (!Object.keys(patch).length) throw new Error('Only environment values changed. Enter them on this source separately.')
102
+ return api.buildImageTemplate({...current.recipe, ...patch, name:current.name}, true)
103
+ }
104
+ throw new Error('This change must be made separately on each source.')
105
+ }
@@ -2,13 +2,16 @@
2
2
  // Quick-setup environment can take minutes, so the dialog hands the work here
3
3
  // and closes; progress, build logs and the result follow the user around the
4
4
  // console as a notification.
5
- export function createSandboxCreations() {
5
+ import { analytics, classifyAnalyticsError } from './analytics.js'
6
+ export function createSandboxCreations({ telemetry = analytics } = {}) {
6
7
  let snapshot = []
7
8
  const listeners = new Set()
8
9
  const runs = new Map()
9
10
  const emit = () => listeners.forEach((listener) => listener())
10
11
  const update = (id, patch) => {
11
12
  snapshot = snapshot.map((job) => job.id === id ? { ...job, ...(typeof patch === "function" ? patch(job) : patch) } : job)
13
+ const job = snapshot.find(item => item.id === id)
14
+ if (job?.status === 'created' && job.phase === 'ready') telemetry.ready(job.analyticsFlow)
12
15
  emit()
13
16
  }
14
17
 
@@ -16,20 +19,22 @@ export function createSandboxCreations() {
16
19
  const job = snapshot.find((item) => item.id === id)
17
20
  const { task } = runs.get(id)
18
21
  const controller = new AbortController()
22
+ const tracked = telemetry.startFlow('sandbox_creation', job.analyticsProperties, job.analyticsFlow)
19
23
  runs.set(id, { task, controller })
20
- update(id, { status: "preparing", message: "Preparing environment…", error: null, build: null })
24
+ update(id, { status: "preparing", message: "Preparing environment…", error: null, build: null, analyticsFlow: tracked })
21
25
  const report = {
22
26
  signal: controller.signal,
23
27
  progress: (message) => update(id, { message }),
24
- build: (build) => update(id, (current) => ({ build: { ...build, logs: build.logs ?? current.build?.logs } })),
25
- creating: () => update(id, { status: "creating", message: "Creating sandbox…" }),
28
+ build: (build) => { telemetry.step(tracked, 'building_image'); update(id, (current) => ({ build: { ...build, logs: build.logs ?? current.build?.logs } })) },
29
+ creating: () => { telemetry.step(tracked, 'creating'); update(id, { status: "creating", message: "Creating sandbox…" }) },
26
30
  }
27
31
  task(report).then((sandbox) => {
28
- if (controller.signal.aborted) return update(id, { status: "cancelled", message: "Creation cancelled." })
32
+ if (controller.signal.aborted) { telemetry.finishFlow(tracked, 'cancelled'); return update(id, { status: "cancelled", message: "Creation cancelled." }) }
33
+ telemetry.finishFlow(tracked, 'created')
29
34
  update(id, { status: "created", sandbox: { ...sandbox, location: sandbox.location ?? job.location }, message: "" })
30
35
  }).catch((error) => {
31
- if (controller.signal.aborted || error.name === "AbortError") update(id, { status: "cancelled", message: "Creation cancelled." })
32
- else update(id, { status: "failed", error: error.message, code: error.code ?? null, fix: error.fix ?? null, message: "" })
36
+ if (controller.signal.aborted || error.name === "AbortError") { telemetry.finishFlow(tracked, 'cancelled'); update(id, { status: "cancelled", message: "Creation cancelled." }) }
37
+ else { telemetry.finishFlow(tracked, 'failed', snapshot.find(item => item.id === id)?.build?.status === 'failed' ? 'image_build' : classifyAnalyticsError(error)); update(id, { status: "failed", error: error.message, code: error.code ?? null, fix: error.fix ?? null, message: "" }) }
33
38
  })
34
39
  }
35
40
 
@@ -37,9 +42,9 @@ export function createSandboxCreations() {
37
42
  subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) },
38
43
  getSnapshot: () => snapshot,
39
44
  // `task` receives { signal, progress, build, creating } and resolves to the created sandbox.
40
- start({ name, location, task }) {
45
+ start({ name, location, task, analyticsProperties, analyticsFlow }) {
41
46
  const id = crypto.randomUUID()
42
- snapshot = [...snapshot, { id, name, location, status: "preparing" }]
47
+ snapshot = [...snapshot, { id, name, location, status: "preparing", analyticsProperties, analyticsFlow }]
43
48
  runs.set(id, { task })
44
49
  run(id)
45
50
  return id
@@ -59,7 +59,11 @@ export function destinationOf(chunk) {
59
59
 
60
60
  export const programName = (path) => (path ? path.split("/").pop() : "Unknown program")
61
61
 
62
- export const ownerOf = (sandbox) => sandbox.owner || sandbox.labels?.["openshell.console/owner"] || sandbox.labels?.owner || "Not reported"
62
+ // Resolve only a matching authenticated account; keep stored ownership untouched.
63
+ export function ownerOf(sandbox, user) {
64
+ const owner = sandbox.owner || sandbox.labels?.["openshell.console/owner"] || sandbox.labels?.owner
65
+ return (owner && owner === user?.uid && user.email) || owner || "Not reported"
66
+ }
63
67
  export const creatorOf = (sandbox) => sandbox.createdBy || sandbox.labels?.["openshell.console/created-by"] || "Not reported"
64
68
 
65
69
  export function elapsedSince(value, now = Date.now()) {
@@ -0,0 +1,10 @@
1
+ export const locationIdentity = location => location ? JSON.stringify([location.target ?? 'local', location.context]) : null
2
+
3
+ // A deep link owns its resources. Preserve that owner even if it goes offline;
4
+ // only an unscoped page may pick a reachable default, preferring this computer.
5
+ export function workingLocation(locations, requested, target = 'local') {
6
+ if (requested) return locations.find(location => locationIdentity(location) === locationIdentity(requested)) ?? { ...requested, connected: false }
7
+ return locations.find(location => location.target === target && !location.remote && location.connected)
8
+ ?? locations.find(location => location.target === target && location.connected)
9
+ ?? null
10
+ }