hereya-cli 0.101.0 → 0.102.0

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.
@@ -1,4 +1,15 @@
1
1
  import { CloudHttpConfig } from './utils.js';
2
+ export declare function cancelExecutorJob(config: CloudHttpConfig, input: {
3
+ jobId: string;
4
+ workspace: string;
5
+ }): Promise<{
6
+ reason: string;
7
+ status?: string;
8
+ success: false;
9
+ } | {
10
+ status: string;
11
+ success: true;
12
+ }>;
2
13
  export declare function generateExecutorToken(config: CloudHttpConfig, input: {
3
14
  workspace: string;
4
15
  }): Promise<{
@@ -27,6 +38,25 @@ export declare function getExecutorJobStatus(config: CloudHttpConfig, input: {
27
38
  reason: string;
28
39
  success: false;
29
40
  }>;
41
+ export declare function listExecutorJobs(config: CloudHttpConfig, input: {
42
+ limit?: number;
43
+ status?: string;
44
+ workspace: string;
45
+ }): Promise<{
46
+ jobs: Array<{
47
+ attempts: number;
48
+ claimedBy: null | string;
49
+ createdAt: string;
50
+ id: string;
51
+ status: string;
52
+ type: string;
53
+ updatedAt: string;
54
+ }>;
55
+ success: true;
56
+ } | {
57
+ reason: string;
58
+ success: false;
59
+ }>;
30
60
  export declare function pollExecutorJobs(config: CloudHttpConfig, input: {
31
61
  executorId?: string;
32
62
  workspace: string;
@@ -58,6 +88,15 @@ export declare function revokeExecutorToken(config: CloudHttpConfig, input: {
58
88
  } | {
59
89
  success: true;
60
90
  }>;
91
+ export declare function rewakeExecutorJob(config: CloudHttpConfig, input: {
92
+ jobId: string;
93
+ workspace: string;
94
+ }): Promise<{
95
+ reason?: string;
96
+ rewoken?: boolean;
97
+ status?: string;
98
+ success: boolean;
99
+ }>;
61
100
  export declare function submitExecutorJob(config: CloudHttpConfig, input: {
62
101
  payload: object;
63
102
  type: 'app-deploy' | 'app-destroy' | 'deploy' | 'destroy' | 'provision' | 'resolve-env' | 'undeploy';
@@ -1,4 +1,20 @@
1
1
  import { safeResponseJson } from './utils.js';
2
+ export async function cancelExecutorJob(config, input) {
3
+ const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/jobs/${encodeURIComponent(input.jobId)}/cancel`, {
4
+ headers: { 'Authorization': `Bearer ${config.accessToken}` },
5
+ method: 'POST',
6
+ });
7
+ if (!response.ok) {
8
+ const error = await safeResponseJson(response);
9
+ return {
10
+ reason: error.error || `Failed to cancel job (status ${response.status})`,
11
+ status: error.status,
12
+ success: false,
13
+ };
14
+ }
15
+ const result = await response.json();
16
+ return { status: result.status, success: true };
17
+ }
2
18
  export async function generateExecutorToken(config, input) {
3
19
  const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/executor-token`, {
4
20
  headers: { 'Authorization': `Bearer ${config.accessToken}` },
@@ -33,6 +49,25 @@ export async function getExecutorJobStatus(config, input) {
33
49
  const result = await response.json();
34
50
  return { job: result.job, success: true };
35
51
  }
52
+ export async function listExecutorJobs(config, input) {
53
+ const url = new URL(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/jobs`);
54
+ if (input.limit !== undefined) {
55
+ url.searchParams.set('limit', String(input.limit));
56
+ }
57
+ if (input.status) {
58
+ url.searchParams.set('status', input.status);
59
+ }
60
+ const response = await fetch(url.toString(), {
61
+ headers: { 'Authorization': `Bearer ${config.accessToken}` },
62
+ method: 'GET',
63
+ });
64
+ if (!response.ok) {
65
+ const error = await safeResponseJson(response);
66
+ return { reason: error.error || 'Failed to list jobs', success: false };
67
+ }
68
+ const result = await response.json();
69
+ return { jobs: result.jobs, success: true };
70
+ }
36
71
  export async function pollExecutorJobs(config, input) {
37
72
  const url = new URL(`${config.url}/api/executor/jobs`);
38
73
  url.searchParams.set('workspace', input.workspace);
@@ -75,6 +110,29 @@ export async function revokeExecutorToken(config, input) {
75
110
  }
76
111
  return { success: true };
77
112
  }
113
+ export async function rewakeExecutorJob(config, input) {
114
+ try {
115
+ const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/jobs/${encodeURIComponent(input.jobId)}/rewake`, {
116
+ body: JSON.stringify({}),
117
+ headers: { 'Authorization': `Bearer ${config.accessToken}`, 'Content-Type': 'application/json' },
118
+ method: 'POST',
119
+ });
120
+ if (!response.ok) {
121
+ const error = await safeResponseJson(response);
122
+ return { reason: error.error || `Rewake failed with status ${response.status}`, success: false };
123
+ }
124
+ const result = await safeResponseJson(response);
125
+ return {
126
+ reason: result.reason,
127
+ rewoken: result.rewoken,
128
+ status: result.status,
129
+ success: Boolean(result.success),
130
+ };
131
+ }
132
+ catch (error) {
133
+ return { reason: error instanceof Error ? error.message : String(error), success: false };
134
+ }
135
+ }
78
136
  export async function submitExecutorJob(config, input) {
79
137
  const formData = new FormData();
80
138
  formData.append('type', input.type);
@@ -27,6 +27,17 @@ export declare class CloudBackend implements Backend {
27
27
  private readonly config;
28
28
  constructor(config: CloudBackendConfig);
29
29
  addPackageToWorkspace(input: Parameters<typeof packagesWorkspace.addPackageToWorkspace>[1]): Promise<import("../common.js").AddPackageToWorkspaceOutput>;
30
+ cancelExecutorJob(input: {
31
+ jobId: string;
32
+ workspace: string;
33
+ }): Promise<{
34
+ reason: string;
35
+ status?: string;
36
+ success: false;
37
+ } | {
38
+ status: string;
39
+ success: true;
40
+ }>;
30
41
  createWorkspace(input: Parameters<typeof workspaces.createWorkspace>[1]): Promise<import("../common.js").CreateWorkspaceOutput>;
31
42
  deleteState(input: Parameters<typeof state.deleteState>[1]): Promise<import("../common.js").DeleteStateOutput>;
32
43
  deleteWorkspace(input: Parameters<typeof workspaces.deleteWorkspace>[1]): Promise<import("../common.js").DeleteWorkspaceOutput>;
@@ -124,6 +135,25 @@ export declare class CloudBackend implements Backend {
124
135
  success: true;
125
136
  versions: appsVersions.AppVersionSummary[];
126
137
  }>;
138
+ listExecutorJobs(input: {
139
+ limit?: number;
140
+ status?: string;
141
+ workspace: string;
142
+ }): Promise<{
143
+ jobs: Array<{
144
+ attempts: number;
145
+ claimedBy: null | string;
146
+ createdAt: string;
147
+ id: string;
148
+ status: string;
149
+ type: string;
150
+ updatedAt: string;
151
+ }>;
152
+ success: true;
153
+ } | {
154
+ reason: string;
155
+ success: false;
156
+ }>;
127
157
  listPackageVersions(name: string): Promise<import("../common.js").ListPackageVersionsOutput>;
128
158
  listProjects(): Promise<import("../common.js").ListProjectsOutput>;
129
159
  listWorkspaces(input?: Parameters<typeof workspaces.listWorkspaces>[1]): Promise<string[]>;
@@ -162,6 +192,15 @@ export declare class CloudBackend implements Backend {
162
192
  } | {
163
193
  success: true;
164
194
  }>;
195
+ rewakeExecutorJob(input: {
196
+ jobId: string;
197
+ workspace: string;
198
+ }): Promise<{
199
+ reason?: string;
200
+ rewoken?: boolean;
201
+ status?: string;
202
+ success: boolean;
203
+ }>;
165
204
  saveProjectMetadata(input: Parameters<typeof projects.saveProjectMetadata>[1]): Promise<import("../common.js").SaveProjectMetadataOutput>;
166
205
  saveState(config: Config, workspace?: string): Promise<void>;
167
206
  searchPackages(input: Parameters<typeof packagesRegistry.searchPackages>[1]): Promise<import("../common.js").SearchPackagesOutput>;
@@ -18,6 +18,9 @@ export class CloudBackend {
18
18
  addPackageToWorkspace(input) {
19
19
  return packagesWorkspace.addPackageToWorkspace(this.config, input);
20
20
  }
21
+ cancelExecutorJob(input) {
22
+ return executorJobs.cancelExecutorJob(this.config, input);
23
+ }
21
24
  createWorkspace(input) {
22
25
  return workspaces.createWorkspace(this.config, input);
23
26
  }
@@ -90,6 +93,9 @@ export class CloudBackend {
90
93
  listAppVersions(name) {
91
94
  return appsVersions.listAppVersions(this.config, name);
92
95
  }
96
+ listExecutorJobs(input) {
97
+ return executorJobs.listExecutorJobs(this.config, input);
98
+ }
93
99
  listPackageVersions(name) {
94
100
  return packagesRegistry.listPackageVersions(this.config, name);
95
101
  }
@@ -120,6 +126,9 @@ export class CloudBackend {
120
126
  revokeExecutorToken(input) {
121
127
  return executorJobs.revokeExecutorToken(this.config, input);
122
128
  }
129
+ rewakeExecutorJob(input) {
130
+ return executorJobs.rewakeExecutorJob(this.config, input);
131
+ }
123
132
  saveProjectMetadata(input) {
124
133
  return projects.saveProjectMetadata(this.config, input);
125
134
  }
@@ -0,0 +1,12 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class JobCancel extends Command {
3
+ static args: {
4
+ jobId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,31 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
3
+ import { getBackend } from '../../../backend/index.js';
4
+ export default class JobCancel extends Command {
5
+ static args = {
6
+ jobId: Args.string({ description: 'id of the remote executor job to cancel', required: true }),
7
+ };
8
+ static description = 'Cancel a pending remote executor job. Only pending jobs can be cancelled.';
9
+ static examples = ['<%= config.bin %> <%= command.id %> my-job-id -w my-workspace'];
10
+ static flags = {
11
+ workspace: Flags.string({
12
+ char: 'w',
13
+ description: 'name of the workspace the job belongs to',
14
+ required: true,
15
+ }),
16
+ };
17
+ async run() {
18
+ const { args, flags } = await this.parse(JobCancel);
19
+ const backend = await getBackend();
20
+ if (!(backend instanceof CloudBackend)) {
21
+ this.error('Cancelling executor jobs requires the cloud backend. Run `hereya login` first.');
22
+ }
23
+ const cloudBackend = backend;
24
+ const result = await cloudBackend.cancelExecutorJob({ jobId: args.jobId, workspace: flags.workspace });
25
+ if (!result.success) {
26
+ const detail = result.status && !result.reason.includes(result.status) ? ` (current status: ${result.status})` : '';
27
+ this.error(`Failed to cancel job ${args.jobId}: ${result.reason}${detail}`);
28
+ }
29
+ this.log(`Job ${args.jobId} cancelled.`);
30
+ }
31
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class JobList extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ limit: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ status: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,54 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
3
+ import { getBackend } from '../../../backend/index.js';
4
+ export default class JobList extends Command {
5
+ static description = 'List remote executor jobs for a workspace, newest first. Pending jobs can be cancelled with `hereya job cancel <jobId>`.';
6
+ static examples = [
7
+ '<%= config.bin %> <%= command.id %> -w my-workspace',
8
+ '<%= config.bin %> <%= command.id %> -w my-workspace --status pending --limit 10',
9
+ ];
10
+ static flags = {
11
+ limit: Flags.integer({
12
+ description: 'maximum number of jobs to list (server default: 20)',
13
+ required: false,
14
+ }),
15
+ status: Flags.string({
16
+ description: 'only list jobs with this status',
17
+ options: ['pending', 'running', 'completed', 'failed', 'cancelled'],
18
+ required: false,
19
+ }),
20
+ workspace: Flags.string({
21
+ char: 'w',
22
+ description: 'name of the workspace to list jobs for',
23
+ required: true,
24
+ }),
25
+ };
26
+ async run() {
27
+ const { flags } = await this.parse(JobList);
28
+ const backend = await getBackend();
29
+ if (!(backend instanceof CloudBackend)) {
30
+ this.error('Listing executor jobs requires the cloud backend. Run `hereya login` first.');
31
+ }
32
+ const cloudBackend = backend;
33
+ const result = await cloudBackend.listExecutorJobs({
34
+ limit: flags.limit,
35
+ status: flags.status,
36
+ workspace: flags.workspace,
37
+ });
38
+ if (!result.success) {
39
+ this.error(`Failed to list jobs: ${result.reason}`);
40
+ }
41
+ if (result.jobs.length === 0) {
42
+ this.log(`No jobs found for workspace ${flags.workspace}.`);
43
+ return;
44
+ }
45
+ const idWidth = Math.max(2, ...result.jobs.map((j) => j.id.length));
46
+ const typeWidth = Math.max(4, ...result.jobs.map((j) => j.type.length));
47
+ const statusWidth = Math.max(6, ...result.jobs.map((j) => j.status.length));
48
+ this.log(`${'Id'.padEnd(idWidth)} ${'Type'.padEnd(typeWidth)} ${'Status'.padEnd(statusWidth)} Created`);
49
+ this.log('-'.repeat(idWidth + typeWidth + statusWidth + 13));
50
+ for (const job of result.jobs) {
51
+ this.log(`${job.id.padEnd(idWidth)} ${job.type.padEnd(typeWidth)} ${job.status.padEnd(statusWidth)} ${new Date(job.createdAt).toLocaleString()}`);
52
+ }
53
+ }
54
+ }
@@ -1,5 +1,16 @@
1
1
  const DEFAULT_TIMEOUT_MIN = 90;
2
2
  const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
3
+ /**
4
+ * How often, while a job is still `pending`, the CLI asks hereya-cloud to
5
+ * re-fire the executor wake. Guards against a wake being swallowed because the
6
+ * ephemeral executor was idle-draining when the job was created.
7
+ */
8
+ const REWAKE_INTERVAL_MS = 60_000;
9
+ /**
10
+ * Grace period before the first re-wake, giving the original creation-time wake
11
+ * a chance to land before we ask for another one.
12
+ */
13
+ const REWAKE_INITIAL_DELAY_MS = 60_000;
3
14
  /**
4
15
  * Default poll timeout, in ms. Overridable via HEREYA_JOB_TIMEOUT_MIN env var
5
16
  * (positive integer, in minutes). Falls back to DEFAULT_TIMEOUT_MIN.
@@ -14,14 +25,39 @@ function defaultTimeoutMs() {
14
25
  }
15
26
  return DEFAULT_TIMEOUT_MIN * 60 * 1000;
16
27
  }
28
+ /**
29
+ * While a job is still `pending` (not yet claimed by any executor), periodically
30
+ * ask hereya-cloud to re-fire the executor wake. Recovers from a creation-time
31
+ * wake being swallowed by an idle-draining ephemeral executor. Any failure is
32
+ * non-fatal — the caller keeps polling regardless. Returns the (possibly
33
+ * updated) `lastRewakeAt` timestamp.
34
+ */
35
+ async function maybeRewake(args) {
36
+ const { cloudBackend, jobId, lastRewakeAt, logger, startTime, status, workspace } = args;
37
+ if (status !== 'pending') {
38
+ return lastRewakeAt;
39
+ }
40
+ const now = Date.now();
41
+ if (now - startTime < REWAKE_INITIAL_DELAY_MS || now - lastRewakeAt < REWAKE_INTERVAL_MS) {
42
+ return lastRewakeAt;
43
+ }
44
+ try {
45
+ await cloudBackend.rewakeExecutorJob({ jobId, workspace });
46
+ }
47
+ catch (error) {
48
+ logger?.debug?.(`Re-wake request failed (non-fatal): ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ return now;
51
+ }
17
52
  export async function pollExecutorJob(input) {
18
53
  const { cloudBackend, jobId, logger, workspace } = input;
19
54
  const timeout = input.timeoutMs ?? defaultTimeoutMs();
20
- logger?.info?.(`Remote executor job submitted (${jobId}). Waiting for result...`);
55
+ logger?.info?.(`Remote executor job submitted (${jobId}). Waiting for result... Cancel with: hereya job cancel ${jobId} -w ${workspace}`);
21
56
  const startTime = Date.now();
22
57
  let lastLogLength = 0;
23
58
  let lastStatus = 'pending';
24
59
  let lastUserHeartbeat = startTime;
60
+ let lastRewakeAt = startTime;
25
61
  while (Date.now() - startTime < timeout) {
26
62
  // eslint-disable-next-line no-await-in-loop
27
63
  const statusResult = await cloudBackend.getExecutorJobStatus({
@@ -53,6 +89,16 @@ export async function pollExecutorJob(input) {
53
89
  lastUserHeartbeat = now;
54
90
  }
55
91
  }
92
+ // eslint-disable-next-line no-await-in-loop
93
+ lastRewakeAt = await maybeRewake({
94
+ cloudBackend,
95
+ jobId,
96
+ lastRewakeAt,
97
+ logger,
98
+ startTime,
99
+ status: job.status,
100
+ workspace,
101
+ });
56
102
  if (job.status === 'completed') {
57
103
  if (job.result) {
58
104
  return { result: job.result, success: true };
@@ -63,6 +109,9 @@ export async function pollExecutorJob(input) {
63
109
  const reason = job.result?.reason || 'Remote executor job failed';
64
110
  return { reason, success: false };
65
111
  }
112
+ if (job.status === 'cancelled') {
113
+ return { reason: 'Job was cancelled', success: false };
114
+ }
66
115
  // Small delay between polls (server-side long polling handles most of the wait)
67
116
  // eslint-disable-next-line no-await-in-loop
68
117
  await new Promise(resolve => {